feat: native v2 workflows endpoint with pluggable stream protocols

Rebased onto release-1.10.0. The base independently rebuilt the v2
workflows backend (RBAC, body globals, share-aware fetch); keep our
forward design and conform its auth to that work:

1. Auth: keep get_current_user_for_workflow (session-or-API-key authN
   that does not hold a DB connection during the inline run, avoiding
   the SQLite lock contention api_key_security would cause) and enforce
   the base's RBAC on top: ensure_flow_permission(EXECUTE) before run,
   (READ) before status reconstruct, with widen_for_shares fetch.
2. Port the base's request-body globals onto the v2 WorkflowRunRequest.
   The X-LANGFLOW-GLOBAL-VAR-* headers stay supported (the Responses API
   passes globals that way); body globals win on conflict. Converters
   echo the effective globals via effective_globals.
3. Public endpoint keeps the v1 build_public_tmp posture
   (access_type==PUBLIC, run-as-owner); RBAC applies to the
   authenticated endpoint only.
4. Preserve the base's post-build KB-cache invalidation in the AG-UI
   build path.

The endpoint, AG-UI bridge, pluggable stream adapters, public endpoint,
and re-attach are unchanged.
This commit is contained in:
ogabrielluiz
2026-06-01 15:39:54 -03:00
parent 7e321059db
commit 88560cb66b
39 changed files with 6872 additions and 2309 deletions

View File

@ -39,6 +39,7 @@ from langflow.api.v1.voice_mode import router as voice_mode_router
from langflow.api.v2 import files_router as files_router_v2
from langflow.api.v2 import mcp_router as mcp_router_v2
from langflow.api.v2 import registration_router as registration_router_v2
from langflow.api.v2 import workflow_public_router as workflow_public_router_v2
from langflow.api.v2 import workflow_router as workflow_router_v2
router_v1 = APIRouter(
@ -124,6 +125,7 @@ router_v2.include_router(files_router_v2)
router_v2.include_router(mcp_router_v2)
router_v2.include_router(registration_router_v2)
router_v2.include_router(workflow_router_v2)
router_v2.include_router(workflow_public_router_v2)
router = APIRouter(
prefix="/api",

View File

@ -45,6 +45,7 @@ from langflow.api.utils.flow_utils import (
build_graph_from_db_no_cache,
cascade_delete_flow,
scope_session_to_namespace,
validate_public_files,
verify_public_flow_and_get_user,
)
@ -90,5 +91,6 @@ __all__ = [
"remove_api_keys",
"scope_session_to_namespace",
"validate_is_component",
"validate_public_files",
"verify_public_flow_and_get_user",
]

View File

@ -2,6 +2,7 @@
from __future__ import annotations
import re
import uuid
from typing import TYPE_CHECKING
@ -131,6 +132,42 @@ async def cascade_delete_flow(session: AsyncSession, flow_id: uuid.UUID) -> None
raise RuntimeError(msg, e) from e
# Public flow file paths must be ``{source_flow_id}/{safe_basename}`` — uploads
# under that namespace are the only legitimate inputs for an unauthenticated
# build. Anything else (absolute paths, traversal, foreign flow_ids) is a
# probe at the arbitrary-file-read class of bug (GHSA-rcjh-r59h-gq37).
_PUBLIC_FILE_PATH_RE = re.compile(
r"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/([^/\\]+)$"
)
_PUBLIC_FILE_REJECTED_SUBSTRINGS = ("\x00", "..", "\\")
def validate_public_files(files: list[str] | None, source_flow_id: uuid.UUID) -> None:
"""Reject file references that aren't ``{source_flow_id}/{basename}``.
Mitigates GHSA-rcjh-r59h-gq37: an unauthenticated build must not be
able to address files outside its own flow's storage namespace.
Called from any endpoint that accepts caller-supplied file references
under a public-access boundary.
"""
if not files:
return
expected_flow_id = str(source_flow_id).lower()
for entry in files:
if not isinstance(entry, str) or not entry:
raise HTTPException(status_code=400, detail="Invalid file entry")
if any(token in entry for token in _PUBLIC_FILE_REJECTED_SUBSTRINGS):
raise HTTPException(status_code=400, detail="Invalid file path")
match = _PUBLIC_FILE_PATH_RE.match(entry)
if not match:
raise HTTPException(status_code=400, detail="Invalid file path format")
flow_id_segment, basename = match.group(1), match.group(2)
if flow_id_segment.lower() != expected_flow_id:
raise HTTPException(status_code=400, detail="File not in this flow's namespace")
if basename in (".", ".."):
raise HTTPException(status_code=400, detail="Invalid filename")
def compute_virtual_flow_id(identifier: str | uuid.UUID, flow_id: uuid.UUID) -> uuid.UUID:
"""Compute a deterministic virtual flow ID for session/message isolation.

View File

@ -1,7 +1,6 @@
from __future__ import annotations
import asyncio
import re
import time
import traceback
import uuid
@ -33,6 +32,7 @@ from langflow.api.utils import (
get_top_level_vertices,
parse_exception,
scope_session_to_namespace,
validate_public_files,
verify_public_flow_and_get_user,
)
from langflow.api.v1.schemas import (
@ -720,34 +720,9 @@ async def build_flow_and_stream(flow_id, inputs, background_tasks, current_user)
)
# Public flow file paths must be `{source_flow_id}/{safe_basename}` — uploads
# under that namespace are the only legitimate inputs for an unauthenticated
# build. Anything else (absolute paths, traversal, foreign flow_ids) is a
# probe at the arbitrary-file-read class of bug.
_PUBLIC_FILE_PATH_RE = re.compile(
r"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})/([^/\\]+)$"
)
_PUBLIC_FILE_REJECTED_SUBSTRINGS = ("\x00", "..", "\\")
def _validate_public_files(files: list[str] | None, source_flow_id: uuid.UUID) -> None:
"""Reject file references that aren't `{source_flow_id}/{basename}`."""
if not files:
return
expected_flow_id = str(source_flow_id).lower()
for entry in files:
if not isinstance(entry, str) or not entry:
raise HTTPException(status_code=400, detail="Invalid file entry")
if any(token in entry for token in _PUBLIC_FILE_REJECTED_SUBSTRINGS):
raise HTTPException(status_code=400, detail="Invalid file path")
match = _PUBLIC_FILE_PATH_RE.match(entry)
if not match:
raise HTTPException(status_code=400, detail="Invalid file path format")
flow_id_segment, basename = match.group(1), match.group(2)
if flow_id_segment.lower() != expected_flow_id:
raise HTTPException(status_code=400, detail="File not in this flow's namespace")
if basename in (".", ".."):
raise HTTPException(status_code=400, detail="Invalid filename")
# NOTE: ``validate_public_files`` (the canonical helper that mitigates
# GHSA-rcjh-r59h-gq37) was moved to ``langflow.api.utils.flow_utils`` so v2's
# public workflow endpoint shares the exact same gate. Keep it imported above.
@router.post("/build_public_tmp/{flow_id}/flow")
@ -810,7 +785,7 @@ async def build_public_tmp(
# Reject caller-supplied file references that aren't scoped to this
# public flow's own storage namespace. Done before any flow lookup so
# malformed requests fail fast and don't touch the DB.
_validate_public_files(files, flow_id)
validate_public_files(files, flow_id)
# Verify this is a public flow and get the associated user
client_id = request.cookies.get("client_id")

View File

@ -4,5 +4,12 @@ from .files import router as files_router
from .mcp import router as mcp_router
from .registration import router as registration_router
from .workflow import router as workflow_router
from .workflow_public import router as workflow_public_router
__all__ = ["files_router", "mcp_router", "registration_router", "workflow_router"]
__all__ = [
"files_router",
"mcp_router",
"registration_router",
"workflow_public_router",
"workflow_router",
]

View File

@ -0,0 +1,118 @@
"""Stream-protocol adapters for the v2 workflows endpoint.
Each adapter translates Langflow ``EventManager`` events into a wire protocol's
SSE event bodies. Adapters register under a string name; the endpoint dispatches
by name based on the ``stream_protocol`` body field. Unknown protocols return
422 with the available list.
Adding a new protocol is one new module under ``adapters/`` plus one
``register_stream_adapter`` call. No endpoint changes.
"""
from __future__ import annotations
from collections.abc import Callable, Iterable
from dataclasses import dataclass
from typing import Any, ClassVar, Protocol, runtime_checkable
from pydantic import BaseModel
@dataclass(frozen=True)
class StreamEvent:
"""One translated event ready to be wrapped in an SSE frame.
``type`` is the protocol-native event type string (e.g. ``"RUN_ERROR"``
for AG-UI, ``"error"`` for langflow). The buffer task uses it to decide
terminal job status. ``data_json`` is the already-serialized JSON string
to place in the SSE ``data:`` field.
"""
type: str
data_json: str
class StreamAdapterContext(BaseModel):
"""Per-run context handed to an adapter at construction.
Extensible: future adapters can need more fields. Add them here when a
real adapter genuinely needs them, not speculatively.
"""
run_id: str
thread_id: str
@runtime_checkable
class StreamAdapter(Protocol):
"""The contract every stream adapter implements.
Adapters are stateful per-run instances. The dispatcher calls
``initial_events`` once before draining the EventManager queue, then
``translate`` per event, then either ``final_events`` (clean end) or
``error_events`` (mid-stream failure). The caller frames the returned
``StreamEvent``s as SSE.
"""
name: ClassVar[str]
def initial_events(self) -> Iterable[StreamEvent]:
"""Events to emit before the run starts (e.g., ``RUN_STARTED``)."""
def translate(self, event_type: str, event_data: dict[str, Any]) -> Iterable[StreamEvent]:
"""Translate one ``EventManager`` event into zero or more wire events."""
def final_events(self) -> Iterable[StreamEvent]:
"""Events to emit after a clean end (e.g., ``RUN_FINISHED``)."""
def error_events(self, error: BaseException) -> Iterable[StreamEvent]:
"""Events to emit when the run errors mid-stream."""
@property
def terminal_error_type(self) -> str | None:
"""Event-type the buffer task watches to flip a background job to FAILED.
Returning ``None`` means the adapter never signals a terminal error
via event type; the buffer task falls back to other signals.
"""
StreamAdapterFactory = Callable[[StreamAdapterContext], StreamAdapter]
class UnknownStreamProtocolError(LookupError):
"""Raised by ``get_stream_adapter`` when no adapter is registered under ``name``."""
def __init__(self, name: str, available: list[str]) -> None:
self.name = name
self.available = available
super().__init__(
f"Unknown stream_protocol {name!r}; available: {available!r}",
)
STREAM_ADAPTERS: dict[str, StreamAdapterFactory] = {}
def register_stream_adapter(name: str, factory: StreamAdapterFactory) -> None:
"""Register an adapter factory under ``name`` (idempotent overwrite)."""
STREAM_ADAPTERS[name] = factory
def get_stream_adapter(name: str, context: StreamAdapterContext) -> StreamAdapter:
"""Build the adapter registered under ``name``; raise if unknown."""
factory = STREAM_ADAPTERS.get(name)
if factory is None:
raise UnknownStreamProtocolError(name, available_protocols())
return factory(context)
def available_protocols() -> list[str]:
"""Sorted list of registered protocol names. Drives the 422 error body."""
return sorted(STREAM_ADAPTERS)
# Built-in adapter registrations happen here, after the registry is defined.
# Import for side-effect: each module calls ``register_stream_adapter``.
from langflow.api.v2.adapters import agui as _agui # noqa: E402, F401
from langflow.api.v2.adapters import langflow as _langflow # noqa: E402, F401

View File

@ -0,0 +1,66 @@
"""The ``agui`` stream adapter: wraps ``AGUITranslator`` for the registry.
Frames each AG-UI ``BaseEvent`` as JSON via ``model_dump_json(by_alias=True,
exclude_none=True)`` so the wire shape stays camelCase and omits unset fields.
Per-run state lives on the wrapped translator instance.
"""
from __future__ import annotations
from collections.abc import Iterable
from typing import Any, ClassVar
from ag_ui.core import BaseEvent, RunErrorEvent
from langflow.api.v2.adapters import (
StreamAdapterContext,
StreamEvent,
register_stream_adapter,
)
from langflow.api.v2.agui_translator import AGUITranslator
def _to_stream_event(event: BaseEvent) -> StreamEvent:
"""Frame one AG-UI event for SSE consumption."""
type_attr = event.type
type_str = type_attr.value if hasattr(type_attr, "value") else str(type_attr)
return StreamEvent(
type=type_str,
data_json=event.model_dump_json(by_alias=True, exclude_none=True),
)
class AGUIAdapter:
"""Wraps ``AGUITranslator``; one instance per run."""
name: ClassVar[str] = "agui"
def __init__(self, context: StreamAdapterContext) -> None:
self.context = context
self._translator = AGUITranslator(
run_id=context.run_id,
thread_id=context.thread_id,
)
def initial_events(self) -> Iterable[StreamEvent]:
return [_to_stream_event(e) for e in self._translator.start()]
def translate(self, event_type: str, event_data: dict[str, Any]) -> Iterable[StreamEvent]:
return [_to_stream_event(e) for e in self._translator.translate(event_type, event_data)]
def final_events(self) -> Iterable[StreamEvent]:
# RUN_FINISHED rides on the translator's ``end`` translation; no extra
# framing is emitted here. Present so ``StreamAdapter`` is satisfied.
return ()
def error_events(self, error: BaseException) -> Iterable[StreamEvent]:
# Fallback path: emitted by the dispatcher when on_error itself fails
# or when no error event has reached the translator.
return [_to_stream_event(RunErrorEvent(message=str(error)))]
@property
def terminal_error_type(self) -> str | None:
return "RUN_ERROR"
register_stream_adapter("agui", AGUIAdapter)

View File

@ -0,0 +1,50 @@
r"""The ``langflow`` stream adapter: passthrough of EventManager events.
Wire shape: ``data: {"event": "<type>", "data": {...}}\n\n`` per SSE frame.
This is the same shape v1's ``build_flow`` already streams, so existing
clients (curl users, the v1 frontend) can read it without learning anything new.
"""
from __future__ import annotations
import json
from collections.abc import Iterable
from typing import Any, ClassVar
from langflow.api.v2.adapters import (
StreamAdapterContext,
StreamEvent,
register_stream_adapter,
)
class LangflowAdapter:
"""Passthrough adapter: each EventManager event becomes one wire event."""
name: ClassVar[str] = "langflow"
def __init__(self, context: StreamAdapterContext) -> None:
self.context = context
def initial_events(self) -> Iterable[StreamEvent]:
return ()
def translate(self, event_type: str, event_data: dict[str, Any]) -> Iterable[StreamEvent]:
payload = {"event": event_type, "data": event_data}
# ``default=str`` keeps non-JSON-serializable values (e.g. component
# objects logged by add_message) from crashing the stream.
return (StreamEvent(type=event_type, data_json=json.dumps(payload, default=str)),)
def final_events(self) -> Iterable[StreamEvent]:
return ()
def error_events(self, error: BaseException) -> Iterable[StreamEvent]:
payload = {"event": "error", "data": {"error": str(error)}}
return (StreamEvent(type="error", data_json=json.dumps(payload)),)
@property
def terminal_error_type(self) -> str | None:
return "error"
register_stream_adapter("langflow", LangflowAdapter)

View File

@ -0,0 +1,302 @@
"""AG-UI translator.
Converts Langflow ``EventManager`` events into AG-UI protocol events. The
``EventManager`` queue is Langflow's internal event seam; this translator is the
one place that maps that vocabulary onto AG-UI, so the v2 workflows endpoint can
stream a standard AG-UI event stream.
One Langflow event may map to several AG-UI events, so ``translate`` returns a
list. The translator is stateful: one instance per run.
"""
from __future__ import annotations
import json
from ag_ui.core import (
BaseEvent,
CustomEvent,
RunErrorEvent,
RunFinishedEvent,
RunStartedEvent,
StateDeltaEvent,
StateSnapshotEvent,
StepFinishedEvent,
StepStartedEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallResultEvent,
ToolCallStartEvent,
)
# Langflow content-block types with no standard AG-UI primitive. They ride as
# CUSTOM events namespaced ``langflow.*``; generic AG-UI clients ignore them.
_CUSTOM_CONTENT_TYPES = frozenset({"json", "code", "media", "error"})
class AGUITranslator:
"""Translates Langflow ``EventManager`` events into AG-UI protocol events.
Use one instance per run. Call :meth:`start` once to open the run, then
:meth:`translate` for each ``EventManager`` event.
"""
def __init__(self, run_id: str, thread_id: str) -> None:
self.run_id = run_id
self.thread_id = thread_id
# Id of the text message currently being streamed by ``token`` events,
# or ``None`` when no message is open.
self._open_message_id: str | None = None
# Message ids already emitted as a complete (non-streamed) text message.
self._emitted_text_message_ids: set[str] = set()
# Tool-call ids already emitted as TOOL_CALL_START / already resolved
# with a TOOL_CALL_RESULT. ``add_message`` can re-fire with the same
# (append-only) content_blocks, so emissions must be deduplicated.
self._started_tool_calls: set[str] = set()
self._resulted_tool_calls: set[str] = set()
# Custom content blocks already emitted as CUSTOM events, mapped to a
# fingerprint of their last-emitted payload so in-place updates re-emit.
self._emitted_content_state: dict[str, str] = {}
def start(self) -> list[BaseEvent]:
"""Open the run.
Emits ``RUN_STARTED`` and an empty node-graph ``STATE_SNAPSHOT``. The
snapshot establishes ``/nodes`` so every later node ``STATE_DELTA`` has a
parent to patch, regardless of which execution path drives the run.
"""
return [
RunStartedEvent(run_id=self.run_id, thread_id=self.thread_id),
StateSnapshotEvent(snapshot={"nodes": {}}),
]
def translate(self, event_type: str, data: dict) -> list[BaseEvent]:
"""Map one ``EventManager`` event to zero or more AG-UI events."""
if event_type == "token":
return self._translate_token(data)
if event_type == "vertices_sorted":
return self._translate_vertices_sorted(data)
if event_type == "build_start":
return self._translate_build_start(data)
if event_type == "end_vertex":
return self._translate_end_vertex(data)
if event_type == "add_message":
return self._translate_add_message(data)
if event_type == "log":
return [CustomEvent(name="langflow.log", value=data)]
if event_type == "remove_message":
return [CustomEvent(name="langflow.message.removed", value={"message_id": str(data.get("id") or "")})]
# Only terminal events close an open text message. Non-terminal events
# (build_start, end_vertex, log, ...) interleave with tokens of the same
# streamed message and must stay transparent, or the message would be
# split into multiple START/END pairs reusing an already-ended id.
if event_type == "end":
events = self._close_open_message()
events.append(RunFinishedEvent(run_id=self.run_id, thread_id=self.thread_id))
return events
if event_type == "error":
events = self._close_open_message()
# The ``error`` payload varies by emission path: a full ErrorMessage
# dump carries the reason in ``text``; the minimal path sends
# ``{"error": str}``.
message = data.get("text") or data.get("error") or "Unknown error"
events.append(RunErrorEvent(message=str(message)))
return events
return []
def _translate_token(self, data: dict) -> list[BaseEvent]:
"""Map a ``token`` event to text-message events.
The first token of a message opens it with ``TEXT_MESSAGE_START``; a
token for a different message id closes the previous one first. A
token whose id was already ended via ``add_message`` (or a prior
token boundary) is dropped: re-opening it would emit a second
``TEXT_MESSAGE_START`` for an id the protocol considers closed.
"""
message_id = str(data.get("id") or "")
if not message_id:
# Without a stable id the AG-UI lifecycle (START/CONTENT/END) cannot
# be correlated. Dropping the event is preferable to emitting a
# malformed stream with empty message_ids.
return []
if message_id in self._emitted_text_message_ids and self._open_message_id != message_id:
return []
chunk = data.get("chunk", "")
events: list[BaseEvent] = []
if self._open_message_id != message_id:
events.extend(self._close_open_message())
events.append(TextMessageStartEvent(message_id=message_id, role="assistant"))
self._open_message_id = message_id
events.append(TextMessageContentEvent(message_id=message_id, delta=chunk))
return events
def _translate_vertices_sorted(self, data: dict) -> list[BaseEvent]:
"""Map ``vertices_sorted`` to a ``STATE_SNAPSHOT`` of the node graph.
Seeds every node that will run with ``pending`` status so the canvas can
render the graph before execution begins. ``to_run`` is the full run set;
``ids`` (the first layer only) is the fallback.
"""
node_ids = data.get("to_run") or data.get("ids") or []
snapshot = {"nodes": {node_id: {"status": "pending", "output": None} for node_id in node_ids}}
return [StateSnapshotEvent(snapshot=snapshot)]
def _translate_build_start(self, data: dict) -> list[BaseEvent]:
"""Map a per-node ``build_start`` to a ``STEP_STARTED`` + a running ``STATE_DELTA``.
The graph-level ``build_start`` (the ``/build`` path) carries no ``id`` and
is a no-op here: ``RUN_STARTED`` already signals the run beginning.
"""
node_id = data.get("id")
if not node_id:
return []
return [
StepStartedEvent(step_name=node_id),
StateDeltaEvent(delta=[self._set_node(node_id, "running", None)]),
]
def _translate_end_vertex(self, data: dict) -> list[BaseEvent]:
"""Map ``end_vertex`` to a ``STEP_FINISHED`` + a ``STATE_DELTA`` for status and output."""
build_data = data.get("build_data") or {}
node_id = build_data.get("id")
if not node_id:
return []
status = "success" if build_data.get("valid") else "error"
return [
StepFinishedEvent(step_name=node_id),
StateDeltaEvent(delta=[self._set_node(node_id, status, build_data.get("data"))]),
]
def _translate_add_message(self, data: dict) -> list[BaseEvent]:
"""Map an ``add_message`` to text-message and tool-call events.
``add_message`` can fire repeatedly for one message as its content grows;
emissions are deduplicated by message id and tool-call id.
"""
message_id = str(data.get("id") or "")
events: list[BaseEvent] = []
# Content blocks: tool_use becomes tool-call events, the Langflow-specific
# content types become namespaced CUSTOM events.
for block_index, block in enumerate(data.get("content_blocks") or []):
if not isinstance(block, dict):
continue
for content_index, content in enumerate(block.get("contents") or []):
if not isinstance(content, dict):
continue
content_type = content.get("type")
if content_type == "tool_use":
events.extend(self._translate_tool_use(message_id, block_index, content_index, content))
elif content_type in _CUSTOM_CONTENT_TYPES:
events.extend(
self._translate_custom_content(message_id, block, block_index, content_index, content)
)
# Message text.
if message_id and message_id == self._open_message_id:
# Finalizer of a token-streamed message: close it. The text was
# already streamed token by token, so it must not be re-emitted now
# or by any later add_message that re-fires for the same id.
self._emitted_text_message_ids.add(message_id)
events.extend(self._close_open_message())
else:
text = data.get("text") or ""
# Skip text-message lifecycle emission without a stable message_id;
# tool-call events above are namespaced by block/content index so
# they can still ride a missing id, but TEXT_MESSAGE_* cannot.
if text and message_id and message_id not in self._emitted_text_message_ids:
self._emitted_text_message_ids.add(message_id)
events.append(TextMessageStartEvent(message_id=message_id, role="assistant"))
events.append(TextMessageContentEvent(message_id=message_id, delta=text))
events.append(TextMessageEndEvent(message_id=message_id))
return events
def _translate_tool_use(
self, message_id: str, block_index: int, content_index: int, content: dict
) -> list[BaseEvent]:
"""Map one ``tool_use`` content block to tool-call lifecycle events.
``ToolContent`` has no id, so a stable tool-call id is derived from the
tool's position in the (append-only) content_blocks structure.
"""
tool_call_id = f"{message_id}:tool:{block_index}:{content_index}"
events: list[BaseEvent] = []
if tool_call_id not in self._started_tool_calls:
self._started_tool_calls.add(tool_call_id)
tool_input = content.get("tool_input")
if tool_input is None:
tool_input = content.get("input")
events.append(
ToolCallStartEvent(
tool_call_id=tool_call_id,
tool_call_name=content.get("name") or "tool",
parent_message_id=message_id,
)
)
events.append(ToolCallArgsEvent(tool_call_id=tool_call_id, delta=json.dumps(tool_input)))
events.append(ToolCallEndEvent(tool_call_id=tool_call_id))
if tool_call_id not in self._resulted_tool_calls:
error = content.get("error")
result = error if error is not None else content.get("output")
if result is not None:
self._resulted_tool_calls.add(tool_call_id)
events.append(
ToolCallResultEvent(
message_id=message_id,
tool_call_id=tool_call_id,
content=result if isinstance(result, str) else json.dumps(result),
)
)
return events
def _translate_custom_content(
self, message_id: str, block: dict, block_index: int, content_index: int, content: dict
) -> list[BaseEvent]:
"""Map a Langflow-specific content block to a namespaced CUSTOM event.
Deduplicated by content state: a re-fired ``add_message`` whose block is
unchanged emits nothing, but an in-place update to the block re-emits.
"""
key = f"{message_id}:content:{block_index}:{content_index}"
fingerprint = json.dumps(content, sort_keys=True, default=str)
if self._emitted_content_state.get(key) == fingerprint:
return []
self._emitted_content_state[key] = fingerprint
return [
CustomEvent(
name=f"langflow.content.{content['type']}",
value={"message_id": message_id, "block_title": block.get("title"), "content": content},
)
]
@staticmethod
def _set_node(node_id: str, status: str, output: object) -> dict:
"""Build the RFC 6902 op that writes a node's state.
``add`` on ``/nodes/{id}`` is create-or-replace: it applies whether or not
the node was pre-seeded by a ``vertices_sorted`` snapshot, so the
translator does not depend on event ordering across execution paths.
"""
return {"op": "add", "path": f"/nodes/{node_id}", "value": {"status": status, "output": output}}
def _close_open_message(self) -> list[BaseEvent]:
"""Emit ``TEXT_MESSAGE_END`` for the open message, if any.
The closed id is recorded in ``_emitted_text_message_ids`` so a later
token (e.g. an interleaved ``A, B, A`` sequence) cannot re-open it
and emit a second ``TEXT_MESSAGE_START`` for an id the protocol
already considers closed.
"""
if self._open_message_id is None:
return []
closed_id = self._open_message_id
end = TextMessageEndEvent(message_id=closed_id)
self._emitted_text_message_ids.add(closed_id)
self._open_message_id = None
return [end]

View File

@ -5,7 +5,7 @@ and the existing V1 schemas, enabling reuse of existing execution logic while
presenting a new API interface.
Key Functions:
- parse_flat_inputs: Converts flat input format to tweaks structure
- parse_workflow_run_request: Parse the native WorkflowRunRequest body
- run_response_to_workflow_response: Converts V1 RunResponse to V2 WorkflowExecutionResponse
- create_error_response: Creates standardized error responses
- create_job_response: Creates background job responses
@ -22,6 +22,7 @@ Internal Helpers:
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import TYPE_CHECKING, Any
@ -30,9 +31,9 @@ from lfx.schema.workflow import (
ErrorDetail,
JobId,
JobStatus,
WorkflowExecutionRequest,
WorkflowExecutionResponse,
WorkflowJobResponse,
WorkflowRunRequest,
)
if TYPE_CHECKING:
@ -41,55 +42,53 @@ if TYPE_CHECKING:
from langflow.api.v1.schemas import RunResponse
def parse_flat_inputs(inputs: dict[str, Any]) -> tuple[dict[str, dict[str, Any]], str | None]:
"""Parse flat inputs structure into tweaks and session_id.
@dataclass(frozen=True)
class ParsedWorkflowRun:
"""Langflow run parameters extracted from a native ``WorkflowRunRequest``."""
Format: {"component_id.param": value}
Example: {"ChatInput-abc.input_value": "hi", "LLM-xyz.temperature": 0.7}
flow_id: str | None
tweaks: dict[str, Any] = field(default_factory=dict)
input_value: str = ""
session_id: str | None = None
run_id: str | None = None
mode: str = "stream"
start_component_id: str | None = None
stop_component_id: str | None = None
# Optional live flow data (nodes + edges) overriding the DB copy. Lets the
# canvas run with unsaved tweaks the user has made but not yet persisted.
data: dict[str, Any] | None = None
# Optional runtime file references the graph build needs.
files: list[str] | None = None
# Request-level global variables from the JSON body (v2 body-globals).
globals: dict[str, str] = field(default_factory=dict)
All parameters (including input_value) are treated as tweaks.
The graph's topological sort handles execution order automatically.
def parse_workflow_run_request(request: WorkflowRunRequest) -> ParsedWorkflowRun:
"""Extract Langflow run parameters from the native ``WorkflowRunRequest`` body.
Every field on the request is first-class; no escape-hatch flattening of
``forwardedProps`` is needed. ``run_id`` is generated by the endpoint, not
the caller, so it is left ``None`` here.
Args:
inputs: The inputs dictionary from WorkflowExecutionRequest
request: The v2 native body shape.
Returns:
Tuple of (tweaks_dict, session_id)
- tweaks_dict: {component_id: {param: value}}
- session_id: Session ID if provided
Example:
>>> inputs = {
... "ChatInput-abc.input_value": "hello",
... "ChatInput-abc.session_id": "session-123",
... "LLM-xyz.temperature": 0.7
... }
>>> tweaks, session_id = parse_flat_inputs(inputs)
>>> tweaks
{'ChatInput-abc': {'input_value': 'hello'}, 'LLM-xyz': {'temperature': 0.7}}
>>> session_id
'session-123'
ParsedWorkflowRun: the Langflow run parameters.
"""
tweaks: dict[str, dict[str, Any]] = {}
session_id: str | None = None
for key, value in inputs.items():
if "." in key:
# Split component_id.param
component_id, param_name = key.split(".", 1)
# Extract session_id if present (use first one found)
if param_name == "session_id" and not session_id:
session_id = value
# Build tweaks for all parameters
if component_id not in tweaks:
tweaks[component_id] = {}
tweaks[component_id][param_name] = value
# No dot - treat as component-level dict (for backward compatibility)
elif isinstance(value, dict):
tweaks[key] = value
return tweaks, session_id
return ParsedWorkflowRun(
flow_id=request.flow_id,
tweaks=request.tweaks,
input_value=request.input_value,
session_id=request.session_id,
run_id=None,
mode=request.mode.value,
start_component_id=request.start_component_id,
stop_component_id=request.stop_component_id,
data=request.data,
files=request.files,
globals=dict(request.globals or {}),
)
def _extract_nested_value(data: Any, *keys: str) -> Any:
@ -384,7 +383,7 @@ def run_response_to_workflow_response(
run_response: RunResponse,
flow_id: str,
job_id: str,
workflow_request: WorkflowExecutionRequest,
inputs: dict[str, Any],
graph: Graph,
effective_globals: dict[str, str] | None = None,
) -> WorkflowExecutionResponse:
@ -409,12 +408,11 @@ def run_response_to_workflow_response(
run_response: The V1 response from simple_run_flow containing execution results
flow_id: The flow identifier
job_id: The generated job ID for tracking this execution
workflow_request: Original workflow request (inputs are echoed back in response)
inputs: Request inputs echoed back in the response
graph: The Graph instance used for terminal node detection and vertex metadata
effective_globals: Optional override for the ``globals`` echoed in the response.
When provided, the response reports the effective merged set (body globals plus
any legacy header-supplied globals). When omitted, ``workflow_request.globals``
is echoed verbatim.
effective_globals: The ``globals`` echoed in the response — the effective merged set
(body globals plus any legacy header-supplied globals). When omitted, an empty
globals map is echoed.
Returns:
WorkflowExecutionResponse: V2 schema response with structured outputs
@ -455,7 +453,7 @@ def run_response_to_workflow_response(
output_key, component_output = _process_terminal_vertex(vertex, output_data_map)
outputs[output_key] = component_output
response_globals = effective_globals if effective_globals is not None else dict(workflow_request.globals or {})
response_globals = effective_globals if effective_globals is not None else {}
return WorkflowExecutionResponse(
flow_id=flow_id,
@ -463,7 +461,7 @@ def run_response_to_workflow_response(
object="response",
status=JobStatus.COMPLETED,
errors=[],
inputs=workflow_request.inputs or {},
inputs=inputs or {},
globals=response_globals,
outputs=outputs,
metadata={},
@ -492,7 +490,7 @@ def create_job_response(job_id: str, flow_id: str) -> WorkflowJobResponse:
def create_error_response(
flow_id: str,
job_id: JobId,
workflow_request: WorkflowExecutionRequest,
inputs: dict[str, Any],
error: Exception,
effective_globals: dict[str, str] | None = None,
) -> WorkflowExecutionResponse:
@ -501,7 +499,7 @@ def create_error_response(
Args:
flow_id: The flow ID
job_id: The job ID
workflow_request: Original request
inputs: Request inputs echoed back in the response
error: The exception that occurred
effective_globals: Optional override for the ``globals`` echoed in the response.
See :func:`run_response_to_workflow_response` for details.
@ -513,7 +511,7 @@ def create_error_response(
error=str(error), code="EXECUTION_ERROR", details={"flow_id": flow_id, "error_type": type(error).__name__}
)
response_globals = effective_globals if effective_globals is not None else dict(workflow_request.globals or {})
response_globals = effective_globals if effective_globals is not None else {}
return WorkflowExecutionResponse(
flow_id=flow_id,
@ -521,7 +519,7 @@ def create_error_response(
object="response",
status=JobStatus.FAILED,
errors=[error_detail],
inputs=workflow_request.inputs or {},
inputs=inputs or {},
globals=response_globals,
outputs={},
metadata={},

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,179 @@
"""V2 public workflow endpoint.
Counterpart of ``/api/v1/build_public_tmp/{flow_id}/flow`` for the v2
streaming pipeline. The shareable playground POSTs here so visitors can
run a public flow without owning it.
The body is intentionally narrower than ``/api/v2/workflows`` (no
``data``, no ``tweaks``): visitors must never override the stored flow
definition. The handler also enforces the public-access mitigations:
- ``access_type == PUBLIC`` (others 403, checked before any policy
validation so private flows leak nothing about themselves).
- Per-visitor ``virtual_flow_id = uuid5(identifier, flow_id)`` used as
the storage flow_id, mirroring v1's build_public_tmp.
- Caller-supplied ``session_id`` namespaced under the virtual flow id
(CVE-2026-33017).
- File-path validation (GHSA-rcjh-r59h-gq37).
- AUTO_LOGIN parity: when ``AUTO_LOGIN=true`` the backend ignores
``authenticated_user.id`` and uses ``client_id`` for the UUID v5
derivation, matching the frontend's ``useGetFlowId`` so the popup's
chat-view filter actually matches what the backend stores.
- Owner impersonation: the run executes under the flow owner's
permissions, never the visitor's.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
from typing import Annotated
from uuid import UUID, uuid4
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException, Request, status
from fastapi.responses import EventSourceResponse
from lfx.schema.workflow import (
WORKFLOW_EXECUTION_RESPONSES,
PublicWorkflowRunRequest,
)
from lfx.utils.flow_validation import CustomComponentValidationError, validate_flow_for_current_settings
from langflow.api.utils.flow_utils import (
scope_session_to_namespace,
validate_public_files,
verify_public_flow_and_get_user,
)
from langflow.api.v2.adapters import (
STREAM_ADAPTERS,
StreamAdapterContext,
UnknownStreamProtocolError,
available_protocols,
get_stream_adapter,
)
from langflow.api.v2.converters import ParsedWorkflowRun
from langflow.services.auth.utils import get_current_user_optional
from langflow.services.database.models.flow.model import Flow
from langflow.services.database.models.user.model import User, UserRead
from langflow.services.deps import get_settings_service, session_scope
router = APIRouter(prefix="/workflows/public", tags=["Workflow (public)"])
@router.post(
"",
responses=WORKFLOW_EXECUTION_RESPONSES,
summary="Execute Public Workflow",
description=(
"Stream a public flow on behalf of a shareable-playground visitor. "
"Mirrors the security posture of /api/v1/build_public_tmp."
),
)
async def execute_public_workflow(
request: PublicWorkflowRunRequest,
background_tasks: BackgroundTasks,
http_request: Request,
authenticated_user: Annotated[User | None, Depends(get_current_user_optional)] = None,
) -> EventSourceResponse:
"""Run a PUBLIC flow on behalf of a shareable-playground visitor.
Stream-mode only. The handler enforces all of the public-access
mitigations documented in the module docstring before any flow
execution begins.
"""
# Lazy-import to avoid the circular ``v2.workflow`` -> ``api.build`` ->
# ``v1.chat`` -> ``api.build`` cycle that fires when ``v2.__init__`` is
# collected at import time.
from langflow.api.v2.workflow import _stream_event_frames, _unknown_protocol_http_exception
real_flow_id = UUID(request.flow_id)
# Mirror v1 ``build_public_tmp`` error contract: blocked-component
# validation must surface as a sanitized 400 (the raw message names
# the disabled component classes); other ValueErrors from the gate
# sequence become 400 with the message preserved.
try:
# File path validation — done before any DB lookup so malformed
# requests fail fast and don't touch the database (GHSA-rcjh-r59h-gq37).
validate_public_files(request.files, real_flow_id)
# Identifier resolution. The frontend's ``useGetFlowId`` uses
# ``client_id`` for the UUID v5 derivation when AUTO_LOGIN is on,
# so the backend must match here or the popup's chat-view filter
# would drop every broadcast message.
client_id = http_request.cookies.get("client_id")
auth_settings = get_settings_service().auth_settings
authenticated_user_id = authenticated_user.id if authenticated_user and not auth_settings.AUTO_LOGIN else None
# access_type == PUBLIC + virtual_flow_id, run as the flow owner.
owner_user, virtual_flow_id = await verify_public_flow_and_get_user(
flow_id=real_flow_id,
client_id=client_id,
authenticated_user_id=authenticated_user_id,
)
# Defends CVE-2026-33017: scope caller's session into the (identifier, flow_id) namespace.
scoped_session = (
scope_session_to_namespace(request.session_id, str(virtual_flow_id))
if request.session_id is not None
else None
)
# Validate stored flow data after the public-access gate so private
# flows never trigger validation side effects.
async with session_scope() as session:
flow = await session.get(Flow, real_flow_id)
if flow and flow.data:
validate_flow_for_current_settings(flow.data)
flow_name = flow.name if flow else None
except CustomComponentValidationError as exc:
# The raw message embeds the blocked component class names; do
# not leak it to an anonymous visitor.
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="This flow cannot be executed.") from exc
except ValueError as exc:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
if request.stream_protocol not in STREAM_ADAPTERS:
raise _unknown_protocol_http_exception(
UnknownStreamProtocolError(request.stream_protocol, available_protocols())
)
job_id = uuid4()
adapter = get_stream_adapter(
request.stream_protocol,
StreamAdapterContext(
run_id=str(job_id),
thread_id=scoped_session or str(virtual_flow_id),
),
)
# The narrower public schema has no ``data``/``tweaks`` fields; we
# carry only the partial-run knobs into ParsedWorkflowRun.
parsed = ParsedWorkflowRun(
flow_id=str(virtual_flow_id),
input_value=request.input_value,
session_id=scoped_session,
run_id=None,
mode="stream",
start_component_id=request.start_component_id,
stop_component_id=request.stop_component_id,
data=None,
files=request.files,
)
owner_user_read = UserRead.model_validate(owner_user, from_attributes=True)
async def _frames_only() -> AsyncIterator[bytes]:
async for frame, _event_type in _stream_event_frames(
adapter=adapter,
flow_id=virtual_flow_id,
flow_name=flow_name,
background_tasks=background_tasks,
parsed=parsed,
current_user=owner_user_read,
source_flow_id=real_flow_id,
):
yield frame
return EventSourceResponse(
_frames_only(),
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)

View File

@ -10,7 +10,6 @@ from typing import TYPE_CHECKING
from lfx.graph.graph.base import Graph
from lfx.graph.schema import ResultData, RunOutputs
from lfx.schema.workflow import WorkflowExecutionRequest
from langflow.api.v1.schemas import RunResponse
from langflow.api.v2.converters import run_response_to_workflow_response
@ -69,12 +68,11 @@ async def reconstruct_workflow_response_from_job_id(
# Create RunResponse and convert to WorkflowExecutionResponse
run_response = RunResponse(outputs=run_outputs_list, session_id=None)
workflow_request = WorkflowExecutionRequest(flow_id=flow_id_str, inputs={})
return run_response_to_workflow_response(
run_response=run_response,
flow_id=flow_id_str,
job_id=job_id,
workflow_request=workflow_request,
inputs={},
graph=graph,
)

View File

@ -9,7 +9,7 @@ from fastapi import Depends, HTTPException, Request, Security, WebSocket, WebSoc
from fastapi.security import APIKeyHeader, APIKeyQuery, OAuth2PasswordBearer
from fastapi.security.utils import get_authorization_scheme_param
from lfx.log.logger import logger
from lfx.services.deps import injectable_session_scope
from lfx.services.deps import injectable_session_scope, session_scope
from langflow.services.auth.exceptions import (
AuthenticationError,
@ -227,6 +227,36 @@ async def get_current_user_for_sse(
) from e
async def get_current_user_for_workflow(
token: Annotated[str | None, Security(oauth2_login)],
query_param: Annotated[str | None, Security(api_key_query)],
header_param: Annotated[str | None, Security(api_key_header)],
) -> UserRead:
"""Combined session-or-API-key auth that does not hold a DB session.
Resolves the user from a session cookie/token *or* an API key inside a
short-lived session that is committed and closed before the path operation
runs. Unlike `get_current_active_user` (a generator dependency whose session
stays open for the whole request), this is required by endpoints that
execute a graph inline: a held auth connection contends with the run's own
writes (on SQLite it blocks the run's INSERTs with "database is locked").
"""
from langflow.services.database.models.user.model import UserRead
async with session_scope() as db:
try:
user = await _auth_service().get_current_user(token, query_param, header_param, db)
except AuthenticationError as e:
raise _auth_error_to_http(e) from e
active_user = await _auth_service().get_current_active_user(user)
if active_user is None:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="User account is inactive",
)
return UserRead.model_validate(active_user, from_attributes=True)
async def get_optional_user(
token: Annotated[str | None, Security(oauth2_login)],
query_param: Annotated[str | None, Security(api_key_query)],

View File

@ -88,6 +88,7 @@ dependencies = [
"networkx>=3.4.2,<4.0.0",
"json-repair>=0.30.3,<1.0.0",
"mcp>=1.17.0,<2.0.0",
"ag-ui-protocol==0.1.18",
"aiosqlite>=0.20.0,<1.0.0",
"greenlet>=3.1.1,<4.0.0",
"jsonquerylang>=1.1.1,<2.0.0",

View File

@ -0,0 +1,179 @@
"""Tests for the ``agui`` stream adapter.
The agui adapter wraps the existing ``AGUITranslator`` and frames its
``BaseEvent`` outputs for SSE. AG-UI's wire shape is the model's
``model_dump_json(by_alias=True, exclude_none=True)``.
"""
from __future__ import annotations
import json
import pytest
from langflow.api.v2.adapters import (
StreamAdapterContext,
get_stream_adapter,
)
def _ctx() -> StreamAdapterContext:
return StreamAdapterContext(run_id="run-7", thread_id="thread-7")
class TestInitialEvents:
"""The agui adapter opens the run with RUN_STARTED + STATE_SNAPSHOT."""
def test_initial_events_emit_run_started_and_state_snapshot(self):
adapter = get_stream_adapter("agui", _ctx())
events = list(adapter.initial_events())
types = [e.type for e in events]
assert types == ["RUN_STARTED", "STATE_SNAPSHOT"]
def test_run_started_carries_run_and_thread_ids(self):
adapter = get_stream_adapter("agui", _ctx())
[run_started, _snapshot] = list(adapter.initial_events())
payload = json.loads(run_started.data_json)
assert payload["runId"] == "run-7"
assert payload["threadId"] == "thread-7"
class TestTranslation:
"""Each ``EventManager`` event maps to its AG-UI equivalent through the translator."""
def test_token_emits_text_message_start_then_content(self):
adapter = get_stream_adapter("agui", _ctx())
events = list(adapter.translate("token", {"id": "m1", "chunk": "Hello"}))
types = [e.type for e in events]
assert "TEXT_MESSAGE_START" in types
assert "TEXT_MESSAGE_CONTENT" in types
def test_end_emits_run_finished(self):
adapter = get_stream_adapter("agui", _ctx())
events = list(adapter.translate("end", {}))
assert any(e.type == "RUN_FINISHED" for e in events)
def test_error_emits_run_error_with_message(self):
adapter = get_stream_adapter("agui", _ctx())
events = list(adapter.translate("error", {"error": "graph build failed"}))
run_error = next(e for e in events if e.type == "RUN_ERROR")
payload = json.loads(run_error.data_json)
assert payload["message"] == "graph build failed"
class TestFinalAndErrorEvents:
"""``final_events`` is empty (RUN_FINISHED comes via translate('end'))."""
def test_final_events_is_empty(self):
adapter = get_stream_adapter("agui", _ctx())
assert list(adapter.final_events()) == []
class TestErrorEventsFallback:
"""``error_events`` is the dispatcher's guaranteed fallback when ``on_error`` itself raises.
``_stream_event_frames`` calls ``adapter.error_events(exc)`` whenever the
translator's own error path fails or no error event reached it. It must
always return at least one ``RUN_ERROR`` frame carrying ``str(exc)``, for
every exception type.
"""
def test_error_events_returns_non_empty_sequence(self):
adapter = get_stream_adapter("agui", _ctx())
events = list(adapter.error_events(ValueError("bad input")))
assert len(events) >= 1
def test_error_events_payload_carries_str_of_exception(self):
adapter = get_stream_adapter("agui", _ctx())
exc = RuntimeError("graph build failed at vertex X")
events = list(adapter.error_events(exc))
payload = json.loads(events[0].data_json)
assert payload["message"] == str(exc)
@pytest.mark.parametrize(
"exc",
[
ValueError("value error message"),
RuntimeError("runtime error message"),
Exception("base exception message"),
KeyError("missing key"),
TypeError("type error message"),
],
)
def test_error_events_works_for_various_exception_types(self, exc):
"""Every exception type yields exactly one RUN_ERROR carrying its str() form."""
adapter = get_stream_adapter("agui", _ctx())
events = list(adapter.error_events(exc))
assert len(events) == 1
assert events[0].type == "RUN_ERROR"
payload = json.loads(events[0].data_json)
# ``str(KeyError("k"))`` is ``"'k'"`` (extra quotes); compare against str(exc).
assert payload["message"] == str(exc)
def test_error_events_does_not_consume_translator_state(self):
"""The fallback path must work even before ``initial_events`` has been drained.
The dispatcher can hit ``error_events`` before any translator state is
opened; the result must still be a valid RUN_ERROR frame.
"""
adapter = get_stream_adapter("agui", _ctx())
events = list(adapter.error_events(RuntimeError("early failure")))
assert len(events) == 1
assert events[0].type == "RUN_ERROR"
class TestTerminalErrorType:
"""Buffer task watches for AG-UI RUN_ERROR to flip the job to FAILED."""
def test_terminal_error_type_is_run_error(self):
adapter = get_stream_adapter("agui", _ctx())
assert adapter.terminal_error_type == "RUN_ERROR"
class TestPerInstance:
"""Each call to ``get_stream_adapter`` returns a fresh adapter (per-run state)."""
def test_two_instances_have_independent_state(self):
a = get_stream_adapter("agui", _ctx())
b = get_stream_adapter("agui", StreamAdapterContext(run_id="r2", thread_id="t2"))
# Drain initial events to advance state.
list(a.initial_events())
list(a.translate("token", {"id": "ma", "chunk": "x"}))
list(b.initial_events())
# b's translate of token "mb" should open a fresh message; a's state
# must not leak into b. If state leaked, b would see ma as still open.
b_events = list(b.translate("token", {"id": "mb", "chunk": "y"}))
types_b = [e.type for e in b_events]
# Fresh START for b's first token confirms isolated state.
assert "TEXT_MESSAGE_START" in types_b
class TestWireFraming:
"""``data_json`` is the AG-UI camelCase wire shape via ``model_dump_json(by_alias=True)``."""
def test_run_started_uses_camel_case_field_names(self):
adapter = get_stream_adapter("agui", _ctx())
[run_started, _snapshot] = list(adapter.initial_events())
# camelCase, not snake_case
assert "runId" in run_started.data_json
assert "threadId" in run_started.data_json
assert "run_id" not in run_started.data_json
def test_data_json_excludes_none_fields(self):
adapter = get_stream_adapter("agui", _ctx())
events = list(adapter.translate("token", {"id": "m1", "chunk": "x"}))
for evt in events:
payload = json.loads(evt.data_json)
for value in payload.values():
assert value is not None, f"None leaked into wire: {evt.data_json}"
@pytest.mark.parametrize(
"unknown_event_type",
["something_brand_new", "vertices_unknown", "telemetry"],
)
def test_unknown_event_types_yield_no_events(unknown_event_type):
adapter = get_stream_adapter("agui", _ctx())
events = list(adapter.translate(unknown_event_type, {}))
assert events == []

View File

@ -0,0 +1,114 @@
"""Tests for the ``langflow`` stream adapter.
The langflow adapter is a passthrough: it takes Langflow EventManager events
and emits them as ``{"event": "<type>", "data": {...}}`` for SSE consumers
that already know the v1 build-flow wire shape.
"""
from __future__ import annotations
import json
import pytest
from langflow.api.v2.adapters import (
StreamAdapterContext,
get_stream_adapter,
)
def _ctx() -> StreamAdapterContext:
return StreamAdapterContext(run_id="run-1", thread_id="thread-1")
class TestPassthroughShape:
"""Each translated event becomes one StreamEvent with the v1 wire shape."""
def test_translate_emits_one_event_per_input(self):
adapter = get_stream_adapter("langflow", _ctx())
events = list(adapter.translate("token", {"chunk": "Hello"}))
assert len(events) == 1
def test_translated_data_matches_v1_build_flow_shape(self):
adapter = get_stream_adapter("langflow", _ctx())
[evt] = list(adapter.translate("token", {"chunk": "Hello", "id": "m1"}))
payload = json.loads(evt.data_json)
assert payload == {"event": "token", "data": {"chunk": "Hello", "id": "m1"}}
def test_event_type_is_set_to_input_event_type(self):
adapter = get_stream_adapter("langflow", _ctx())
[evt] = list(adapter.translate("end_vertex", {"node_id": "x", "valid": True}))
assert evt.type == "end_vertex"
class TestNoFramingFromInitialAndFinal:
"""The langflow adapter has no setup or teardown frames; SSE itself terminates the stream."""
def test_initial_events_is_empty(self):
adapter = get_stream_adapter("langflow", _ctx())
assert list(adapter.initial_events()) == []
def test_final_events_is_empty(self):
adapter = get_stream_adapter("langflow", _ctx())
assert list(adapter.final_events()) == []
class TestErrorHandling:
"""Errors mid-run emit a single ``error`` event and are terminal for the run."""
def test_error_events_emit_error_payload(self):
adapter = get_stream_adapter("langflow", _ctx())
[evt] = list(adapter.error_events(RuntimeError("boom")))
payload = json.loads(evt.data_json)
assert payload == {"event": "error", "data": {"error": "boom"}}
assert evt.type == "error"
def test_terminal_error_type_is_error(self):
"""Used by the buffer task to decide JobStatus.FAILED."""
adapter = get_stream_adapter("langflow", _ctx())
assert adapter.terminal_error_type == "error"
class TestPayloadSerialization:
"""``data`` is serialized to a JSON string; non-JSON values are coerced safely."""
def test_dict_with_nested_values_round_trips(self):
adapter = get_stream_adapter("langflow", _ctx())
data = {"text": "hi", "meta": {"k": [1, 2, 3]}}
[evt] = list(adapter.translate("add_message", data))
assert json.loads(evt.data_json) == {"event": "add_message", "data": data}
def test_non_serializable_values_fall_back_to_str(self):
adapter = get_stream_adapter("langflow", _ctx())
class Custom:
def __str__(self) -> str:
return "<custom>"
[evt] = list(adapter.translate("log", {"obj": Custom()}))
payload = json.loads(evt.data_json)
# The adapter should not raise; the custom object is stringified.
assert payload["data"]["obj"] == "<custom>"
class TestEventTypeForwarding:
"""Every Langflow EventManager event type round-trips through the adapter."""
@pytest.mark.parametrize(
"event_type",
[
"token",
"vertices_sorted",
"build_start",
"end_vertex",
"add_message",
"remove_message",
"log",
"end",
"error",
],
)
def test_known_event_types_are_passed_through(self, event_type):
adapter = get_stream_adapter("langflow", _ctx())
[evt] = list(adapter.translate(event_type, {"k": "v"}))
assert evt.type == event_type
assert json.loads(evt.data_json)["event"] == event_type

View File

@ -0,0 +1,123 @@
"""Contract tests for the stream-adapter registry.
The registry pattern lets us add new wire protocols (AG-UI, OpenAI Responses,
Vercel AI SDK, ...) without changing the v2 workflows endpoint. Each adapter
registers under a string name; the endpoint dispatches by name and returns
422 with the available list on unknown values.
"""
from __future__ import annotations
import pytest
from langflow.api.v2.adapters import (
STREAM_ADAPTERS,
StreamAdapterContext,
StreamEvent,
UnknownStreamProtocolError,
available_protocols,
get_stream_adapter,
register_stream_adapter,
)
@pytest.fixture
def isolated_registry():
"""Snapshot and restore ``STREAM_ADAPTERS`` so tests don't leak registrations."""
snapshot = dict(STREAM_ADAPTERS)
try:
yield STREAM_ADAPTERS
finally:
STREAM_ADAPTERS.clear()
STREAM_ADAPTERS.update(snapshot)
def _ctx() -> StreamAdapterContext:
return StreamAdapterContext(run_id="run-1", thread_id="thread-1")
class _NoopAdapter:
"""Minimal adapter implementing the Protocol surface."""
name = "noop"
def __init__(self, context: StreamAdapterContext) -> None:
self.context = context
def initial_events(self):
return []
def translate(self, event_type, _event_data):
return [StreamEvent(type=event_type, data_json="{}")]
def final_events(self):
return []
def error_events(self, _error):
return []
@property
def terminal_error_type(self):
return None
class TestBuiltinProtocols:
"""The built-in adapters are registered at import time."""
def test_langflow_protocol_is_available_by_default(self):
assert "langflow" in available_protocols()
def test_agui_protocol_is_available_by_default(self):
assert "agui" in available_protocols()
class TestRegistration:
"""Registering a new protocol makes it available via lookup."""
def test_registered_adapter_can_be_resolved(self, isolated_registry): # noqa: ARG002 — fixture used for snapshot/restore side effect
register_stream_adapter("noop", _NoopAdapter)
adapter = get_stream_adapter("noop", _ctx())
assert isinstance(adapter, _NoopAdapter)
assert adapter.context.run_id == "run-1"
assert adapter.context.thread_id == "thread-1"
def test_available_protocols_includes_new_registration(self, isolated_registry): # noqa: ARG002 — fixture used for snapshot/restore side effect
register_stream_adapter("noop", _NoopAdapter)
assert "noop" in available_protocols()
def test_available_protocols_is_sorted(self, isolated_registry): # noqa: ARG002 — fixture used for snapshot/restore side effect
register_stream_adapter("zzz-last", _NoopAdapter)
register_stream_adapter("aaa-first", _NoopAdapter)
names = available_protocols()
assert names == sorted(names)
class TestUnknownProtocol:
"""Looking up an unknown protocol raises with the available list."""
def test_unknown_protocol_raises_with_available_list(self, isolated_registry): # noqa: ARG002 — fixture used for snapshot/restore side effect
with pytest.raises(UnknownStreamProtocolError) as exc:
get_stream_adapter("nonexistent", _ctx())
assert exc.value.name == "nonexistent"
assert "langflow" in exc.value.available
# The error should be inspectable; the string form should mention both
# the unknown name and the available names so a 422 detail can include them.
msg = str(exc.value)
assert "nonexistent" in msg
assert "langflow" in msg
class TestStreamEvent:
"""``StreamEvent`` is the contract between adapter and SSE framer."""
def test_stream_event_is_immutable(self):
evt = StreamEvent(type="token", data_json='{"event":"token"}')
with pytest.raises((AttributeError, TypeError, Exception)):
evt.type = "other" # type: ignore[misc]
def test_stream_event_carries_type_and_serialized_data(self):
evt = StreamEvent(type="RUN_STARTED", data_json='{"type":"RUN_STARTED"}')
assert evt.type == "RUN_STARTED"
assert evt.data_json == '{"type":"RUN_STARTED"}'

View File

@ -0,0 +1,700 @@
"""Unit tests for the AG-UI translator.
The translator consumes Langflow ``EventManager`` events and emits AG-UI
protocol events. These tests feed real ``EventManager`` event payloads and
assert on the emitted AG-UI event objects. No mocks: the translator is a pure,
stateful transformation with no I/O.
"""
from __future__ import annotations
from ag_ui.core import (
CustomEvent,
RunErrorEvent,
RunFinishedEvent,
RunStartedEvent,
StateDeltaEvent,
StateSnapshotEvent,
StepFinishedEvent,
StepStartedEvent,
TextMessageContentEvent,
TextMessageEndEvent,
TextMessageStartEvent,
ToolCallArgsEvent,
ToolCallEndEvent,
ToolCallResultEvent,
ToolCallStartEvent,
)
from langflow.api.v2.agui_translator import AGUITranslator
def test_run_lifecycle_emits_started_and_finished():
t = AGUITranslator(run_id="r1", thread_id="t1")
started = t.start()
ended = t.translate("end", {})
assert isinstance(started[0], RunStartedEvent)
assert started[0].run_id == "r1"
assert started[0].thread_id == "t1"
assert isinstance(ended[0], RunFinishedEvent)
assert ended[0].run_id == "r1"
assert ended[0].thread_id == "t1"
def test_error_emits_run_error():
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
out = t.translate("error", {"error": "boom"})
assert isinstance(out[0], RunErrorEvent)
assert "boom" in out[0].message
def test_error_reads_text_from_error_message_payload():
"""``error`` can carry a full ErrorMessage dump whose reason is in ``text``."""
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
out = t.translate("error", {"text": "component blew up", "category": "error"})
assert isinstance(out[0], RunErrorEvent)
assert "component blew up" in out[0].message
def test_token_sequence_emits_start_contents_then_end_on_boundary():
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
first = t.translate("token", {"chunk": "Hel", "id": "m1"})
second = t.translate("token", {"chunk": "lo", "id": "m1"})
ended = t.translate("end", {})
# First token opens the message and carries its delta.
assert isinstance(first[0], TextMessageStartEvent)
assert first[0].message_id == "m1"
assert isinstance(first[1], TextMessageContentEvent)
assert first[1].message_id == "m1"
assert first[1].delta == "Hel"
# Subsequent tokens are content only, same message id.
assert len(second) == 1
assert isinstance(second[0], TextMessageContentEvent)
assert second[0].message_id == "m1"
assert second[0].delta == "lo"
# The end boundary closes the open message before finishing the run.
assert isinstance(ended[0], TextMessageEndEvent)
assert ended[0].message_id == "m1"
assert isinstance(ended[1], RunFinishedEvent)
def test_new_message_id_closes_previous_message_and_opens_new():
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
t.translate("token", {"chunk": "a", "id": "m1"})
out = t.translate("token", {"chunk": "b", "id": "m2"})
assert isinstance(out[0], TextMessageEndEvent)
assert out[0].message_id == "m1"
assert isinstance(out[1], TextMessageStartEvent)
assert out[1].message_id == "m2"
assert isinstance(out[2], TextMessageContentEvent)
assert out[2].message_id == "m2"
assert out[2].delta == "b"
def test_error_boundary_closes_open_text_message():
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
t.translate("token", {"chunk": "partial", "id": "m1"})
out = t.translate("error", {"error": "boom"})
assert isinstance(out[0], TextMessageEndEvent)
assert out[0].message_id == "m1"
assert isinstance(out[1], RunErrorEvent)
def test_interleaved_non_terminal_event_does_not_split_open_message():
"""A non-terminal event between tokens must not close the streamed message.
Langflow's agent streams one message id for its whole response while other
events (build_start, add_message for other messages, log) interleave. Closing
the message on those would split it into two START/END pairs reusing an
already-ended id, which is malformed AG-UI.
"""
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
t.translate("token", {"chunk": "Hel", "id": "m1"})
interleaved = t.translate("build_start", {"id": "node-x"})
more = t.translate("token", {"chunk": "lo", "id": "m1"})
# The interleaved non-terminal event must not close the open message.
assert all(not isinstance(e, TextMessageEndEvent) for e in interleaved)
# The continuing token must not re-open an already-open message.
assert all(not isinstance(e, TextMessageStartEvent) for e in more)
assert isinstance(more[0], TextMessageContentEvent)
assert more[0].message_id == "m1"
assert more[0].delta == "lo"
def test_token_for_already_ended_message_id_is_dropped():
"""A ``token`` arriving after ``add_message`` ended that id must not re-open it.
Some Langflow agents fire ``add_message`` with the final text first, which
emits START/CONTENT/END for the id and records it in
``_emitted_text_message_ids``. Late tokens for the same id used to emit a
fresh ``TextMessageStartEvent`` for an already-ended message id, which is
malformed AG-UI. The translator must drop those tokens silently.
"""
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
# add_message emits START/CONTENT/END for m1 and records m1 as emitted.
add_message_events = t.translate("add_message", {"id": "m1", "text": "hello", "sender": "AI"})
assert any(isinstance(e, TextMessageEndEvent) for e in add_message_events)
# A late token for the same id must not re-open the ended message.
late = t.translate("token", {"chunk": "x", "id": "m1"})
assert all(not isinstance(e, TextMessageStartEvent) for e in late), (
f"Late token re-opened an already-ended message id; emitted: {late}"
)
def test_token_after_boundary_close_is_dropped():
"""An interleaved token sequence ``A, B, A`` must not re-open id A.
Switching from id A to id B closes A through ``_close_open_message``. A
later token for A used to slip past the dedup guard because
``_close_open_message`` was the only finalizer that did not record the
closed id in ``_emitted_text_message_ids``. The translator must treat a
token-boundary close the same way it treats an ``add_message`` close.
"""
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
a1 = t.translate("token", {"chunk": "hi", "id": "m1"})
assert any(isinstance(e, TextMessageStartEvent) and e.message_id == "m1" for e in a1)
# Switching to a new id closes m1 via _close_open_message.
b = t.translate("token", {"chunk": "yo", "id": "m2"})
assert any(isinstance(e, TextMessageEndEvent) and e.message_id == "m1" for e in b)
assert any(isinstance(e, TextMessageStartEvent) and e.message_id == "m2" for e in b)
# A late token for m1 must be dropped, not re-open the ended message.
late = t.translate("token", {"chunk": "again", "id": "m1"})
assert all(not isinstance(e, TextMessageStartEvent) for e in late), (
f"Token boundary did not mark m1 as ended; emitted: {late}"
)
assert late == [], f"Expected no events for late token after boundary close; got {late}"
def test_vertices_sorted_emits_state_snapshot_of_all_nodes():
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
out = t.translate("vertices_sorted", {"ids": ["a"], "to_run": ["a", "b", "c"]})
assert len(out) == 1
assert isinstance(out[0], StateSnapshotEvent)
nodes = out[0].snapshot["nodes"]
# The snapshot covers the full run set (to_run), not just the first layer.
assert set(nodes) == {"a", "b", "c"}
for node in nodes.values():
assert node["status"] == "pending"
assert node["output"] is None
def test_vertices_sorted_falls_back_to_ids_when_to_run_absent():
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
out = t.translate("vertices_sorted", {"ids": ["a", "b"]})
assert isinstance(out[0], StateSnapshotEvent)
assert set(out[0].snapshot["nodes"]) == {"a", "b"}
def test_start_establishes_empty_node_state():
"""start() must seed the state container so later node deltas always apply."""
t = AGUITranslator(run_id="r1", thread_id="t1")
started = t.start()
assert isinstance(started[0], RunStartedEvent)
snapshots = [e for e in started if isinstance(e, StateSnapshotEvent)]
assert snapshots
assert snapshots[0].snapshot == {"nodes": {}}
def test_build_start_for_node_emits_step_started_and_running_delta():
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
out = t.translate("build_start", {"id": "node-x"})
assert isinstance(out[0], StepStartedEvent)
assert out[0].step_name == "node-x"
assert isinstance(out[1], StateDeltaEvent)
op = out[1].delta[0]
assert op["op"] == "add"
assert op["path"] == "/nodes/node-x"
assert op["value"] == {"status": "running", "output": None}
def test_graph_level_build_start_with_no_id_is_noop():
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
# The graph-level build_start (the /build path) carries no node id;
# RUN_STARTED already signals the run beginning.
assert t.translate("build_start", {}) == []
def test_end_vertex_success_emits_step_finished_and_state_delta():
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
out = t.translate(
"end_vertex",
{"build_data": {"id": "node-x", "valid": True, "data": {"outputs": {"out": "hello"}}}},
)
assert isinstance(out[0], StepFinishedEvent)
assert out[0].step_name == "node-x"
assert isinstance(out[1], StateDeltaEvent)
op = out[1].delta[0]
assert op["op"] == "add"
assert op["path"] == "/nodes/node-x"
assert op["value"] == {"status": "success", "output": {"outputs": {"out": "hello"}}}
def test_end_vertex_failure_sets_error_status():
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
out = t.translate("end_vertex", {"build_data": {"id": "node-x", "valid": False, "data": None}})
op = out[1].delta[0]
assert op["value"]["status"] == "error"
def test_node_state_deltas_use_add_so_they_apply_without_vertices_sorted():
"""build_start/end_vertex must not depend on vertices_sorted seeding the node.
Per-node build events and vertices_sorted come from different Langflow
execution paths that do not co-occur. start() establishes ``/nodes`` and node
deltas use ``add`` on ``/nodes/{id}`` (create-or-replace), so the JSON Patch
applies cleanly whether or not a STATE_SNAPSHOT seeded the node first.
"""
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start() # no vertices_sorted in this path
build = t.translate("build_start", {"id": "node-x"})
end = t.translate("end_vertex", {"build_data": {"id": "node-x", "valid": True, "data": "ok"}})
for events in (build, end):
delta = next(e for e in events if isinstance(e, StateDeltaEvent))
op = delta.delta[0]
assert op["op"] == "add"
assert op["path"] == "/nodes/node-x"
def test_end_vertex_does_not_close_open_text_message():
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
t.translate("token", {"chunk": "Hel", "id": "m1"})
t.translate("end_vertex", {"build_data": {"id": "node-x", "valid": True, "data": {}}})
more = t.translate("token", {"chunk": "lo", "id": "m1"})
assert all(not isinstance(e, TextMessageStartEvent) for e in more)
assert isinstance(more[0], TextMessageContentEvent)
assert more[0].message_id == "m1"
def test_token_without_message_id_is_dropped():
"""Token events with missing ``id`` cannot drive the AG-UI lifecycle.
A TextMessageStart/Content/End triple requires a stable id to correlate
chunks. Emitting events with ``message_id=""`` produces malformed
streams that some AG-UI clients reject; drop the event instead.
"""
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
no_id = t.translate("token", {"chunk": "hello"})
none_id = t.translate("token", {"chunk": "hello", "id": None})
empty_id = t.translate("token", {"chunk": "hello", "id": ""})
assert no_id == []
assert none_id == []
assert empty_id == []
def test_add_message_without_message_id_skips_text_lifecycle():
"""``add_message`` without an id must not emit TextMessage* events.
Tool-call sub-events can still ride a missing id (they namespace by
block/content index), but the text lifecycle needs a stable id.
"""
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
out = t.translate("add_message", {"text": "hello"})
assert all(not isinstance(e, TextMessageStartEvent) for e in out)
assert all(not isinstance(e, TextMessageContentEvent) for e in out)
assert all(not isinstance(e, TextMessageEndEvent) for e in out)
def test_add_message_plain_text_emits_a_text_message():
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
out = t.translate("add_message", {"id": "m1", "text": "The weather is sunny."})
assert isinstance(out[0], TextMessageStartEvent)
assert out[0].message_id == "m1"
assert isinstance(out[1], TextMessageContentEvent)
assert out[1].delta == "The weather is sunny."
assert isinstance(out[2], TextMessageEndEvent)
assert out[2].message_id == "m1"
def test_add_message_finalizing_a_streamed_message_does_not_duplicate_text():
"""A streamed message's add_message finalizer only closes it, never re-emits text."""
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
t.translate("token", {"chunk": "The weather ", "id": "m1"})
t.translate("token", {"chunk": "is sunny.", "id": "m1"})
out = t.translate("add_message", {"id": "m1", "text": "The weather is sunny."})
assert all(not isinstance(e, TextMessageStartEvent) for e in out)
assert all(not isinstance(e, TextMessageContentEvent) for e in out)
assert isinstance(out[0], TextMessageEndEvent)
assert out[0].message_id == "m1"
def test_add_message_tool_use_emits_tool_call_lifecycle():
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
out = t.translate(
"add_message",
{
"id": "m1",
"text": "",
"content_blocks": [
{
"title": "Agent Steps",
"contents": [
{
"type": "tool_use",
"name": "search",
"tool_input": {"query": "weather"},
"output": "sunny",
"error": None,
}
],
}
],
},
)
starts = [e for e in out if isinstance(e, ToolCallStartEvent)]
args = [e for e in out if isinstance(e, ToolCallArgsEvent)]
ends = [e for e in out if isinstance(e, ToolCallEndEvent)]
results = [e for e in out if isinstance(e, ToolCallResultEvent)]
assert len(starts) == 1
assert starts[0].tool_call_name == "search"
assert starts[0].parent_message_id == "m1"
assert len(args) == 1
assert "weather" in args[0].delta
assert len(ends) == 1
assert len(results) == 1
assert "sunny" in results[0].content
# The four events share one tool_call_id.
assert {starts[0].tool_call_id, args[0].tool_call_id, ends[0].tool_call_id, results[0].tool_call_id} == {
starts[0].tool_call_id
}
def test_tool_use_error_is_reported_via_tool_call_result():
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
out = t.translate(
"add_message",
{
"id": "m1",
"content_blocks": [
{
"title": "Agent Steps",
"contents": [
{
"type": "tool_use",
"name": "search",
"tool_input": {},
"output": None,
"error": "rate limited",
}
],
}
],
},
)
results = [e for e in out if isinstance(e, ToolCallResultEvent)]
assert len(results) == 1
assert "rate limited" in results[0].content
def test_repeated_add_message_does_not_re_emit_the_same_tool_call():
"""A tool call already emitted is not re-emitted when add_message re-fires."""
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
payload = {
"id": "m1",
"content_blocks": [
{
"title": "Agent Steps",
"contents": [
{"type": "tool_use", "name": "search", "tool_input": {"q": "x"}, "output": "done", "error": None}
],
}
],
}
first = t.translate("add_message", payload)
second = t.translate("add_message", payload)
assert len([e for e in first if isinstance(e, ToolCallStartEvent)]) == 1
assert second == []
def test_repeated_add_message_after_streamed_finalizer_does_not_duplicate_text():
"""A streamed message finalized once must not re-emit text on a later add_message."""
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
t.translate("token", {"chunk": "Hello", "id": "m1"})
t.translate("add_message", {"id": "m1", "text": "Hello"}) # finalizes the streamed message
again = t.translate("add_message", {"id": "m1", "text": "Hello"}) # re-fire
assert again == []
def test_malformed_content_block_is_skipped_not_crashed():
"""A null or non-dict entry in content_blocks is skipped, not fatal."""
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
out = t.translate("add_message", {"id": "m1", "text": "hi", "content_blocks": [None, "garbage"]})
assert any(isinstance(e, TextMessageStartEvent) for e in out)
def test_custom_content_types_emit_langflow_custom_events():
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
out = t.translate(
"add_message",
{
"id": "m1",
"content_blocks": [
{
"title": "Steps",
"contents": [
{"type": "json", "data": {"k": "v"}},
{"type": "code", "code": "print(1)", "language": "python"},
{"type": "media", "urls": ["http://x/img.png"], "caption": "pic"},
{"type": "error", "reason": "boom", "traceback": "trace"},
],
}
],
},
)
customs = {e.name: e for e in out if isinstance(e, CustomEvent)}
assert set(customs) == {
"langflow.content.json",
"langflow.content.code",
"langflow.content.media",
"langflow.content.error",
}
assert customs["langflow.content.json"].value["message_id"] == "m1"
assert customs["langflow.content.json"].value["block_title"] == "Steps"
assert customs["langflow.content.code"].value["content"]["code"] == "print(1)"
def test_log_event_emits_langflow_log_custom_event():
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
out = t.translate("log", {"message": "hi", "type": "text", "name": "Log 1", "component_id": "c1"})
assert len(out) == 1
assert isinstance(out[0], CustomEvent)
assert out[0].name == "langflow.log"
assert out[0].value["component_id"] == "c1"
def test_remove_message_emits_langflow_removed_custom_event():
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
out = t.translate("remove_message", {"id": "m9"})
assert len(out) == 1
assert isinstance(out[0], CustomEvent)
assert out[0].name == "langflow.message.removed"
assert out[0].value["message_id"] == "m9"
def test_repeated_custom_content_block_is_not_re_emitted():
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
payload = {
"id": "m1",
"content_blocks": [{"title": "Steps", "contents": [{"type": "json", "data": {"k": "v"}}]}],
}
first = t.translate("add_message", payload)
second = t.translate("add_message", payload)
assert len([e for e in first if isinstance(e, CustomEvent)]) == 1
assert second == []
def test_updated_custom_content_block_is_re_emitted():
"""If a content block's payload changes on a later add_message, the update is emitted."""
t = AGUITranslator(run_id="r1", thread_id="t1")
t.start()
t.translate(
"add_message",
{"id": "m1", "content_blocks": [{"title": "S", "contents": [{"type": "json", "data": {}}]}]},
)
out = t.translate(
"add_message",
{"id": "m1", "content_blocks": [{"title": "S", "contents": [{"type": "json", "data": {"k": "v"}}]}]},
)
customs = [e for e in out if isinstance(e, CustomEvent)]
assert len(customs) == 1
assert customs[0].value["content"]["data"] == {"k": "v"}
# --- Full-sequence integration coverage ---------------------------------------
def _run_sequence(translator: AGUITranslator, events: list[tuple[str, dict]]) -> list:
"""Feed start() then a list of (event_type, data) pairs, collecting all output."""
out = list(translator.start())
for event_type, data in events:
out.extend(translator.translate(event_type, data))
return out
def _assert_well_formed(events: list) -> None:
"""Assert an emitted AG-UI stream is structurally valid."""
assert events, "expected a non-empty event stream"
assert isinstance(events[0], RunStartedEvent)
assert isinstance(events[-1], (RunFinishedEvent, RunErrorEvent))
open_messages: set[str] = set()
seen_messages: set[str] = set()
for event in events:
if isinstance(event, TextMessageStartEvent):
assert event.message_id not in open_messages, "text message started while already open"
assert event.message_id not in seen_messages, "text message id reused after it ended"
open_messages.add(event.message_id)
seen_messages.add(event.message_id)
elif isinstance(event, TextMessageContentEvent):
assert event.message_id in open_messages, "text content for a message that is not open"
elif isinstance(event, TextMessageEndEvent):
assert event.message_id in open_messages, "text message ended without being open"
open_messages.discard(event.message_id)
assert not open_messages, "text messages left unclosed"
started_tools: set[str] = set()
ended_tools: set[str] = set()
for event in events:
if isinstance(event, ToolCallStartEvent):
assert event.tool_call_id not in started_tools, "tool call started twice"
started_tools.add(event.tool_call_id)
elif isinstance(event, ToolCallArgsEvent):
assert event.tool_call_id in started_tools, "tool args before tool start"
elif isinstance(event, ToolCallEndEvent):
ended_tools.add(event.tool_call_id)
assert started_tools == ended_tools, "every tool call needs a matching START and END"
_AGENT_STEPS = [
{
"title": "Agent Steps",
"contents": [
{"type": "tool_use", "name": "search", "tool_input": {"q": "weather"}, "output": "sunny", "error": None}
],
}
]
def test_full_agent_flow_sequence_is_well_formed():
"""A realistic ChatInput -> Agent -> ChatOutput run yields a well-formed stream."""
t = AGUITranslator(run_id="run-1", thread_id="sess-1")
sequence = [
("vertices_sorted", {"ids": ["ChatInput-a"], "to_run": ["ChatInput-a", "Agent-b", "ChatOutput-c"]}),
("build_start", {}),
("end_vertex", {"build_data": {"id": "ChatInput-a", "valid": True, "data": {"outputs": {}}}}),
# The agent surfaces its tool step on a partial message update.
("add_message", {"id": "m1", "text": "", "properties": {"state": "partial"}, "content_blocks": _AGENT_STEPS}),
# The agent streams its final answer token by token.
("token", {"chunk": "It is ", "id": "m1"}),
("token", {"chunk": "sunny.", "id": "m1"}),
# The complete message finalizes the streamed answer.
(
"add_message",
{"id": "m1", "text": "It is sunny.", "properties": {"state": "complete"}, "content_blocks": _AGENT_STEPS},
),
("end_vertex", {"build_data": {"id": "Agent-b", "valid": True, "data": {"outputs": {}}}}),
("end_vertex", {"build_data": {"id": "ChatOutput-c", "valid": True, "data": {"outputs": {}}}}),
("end", {"build_duration": 1.23}),
]
out = _run_sequence(t, sequence)
_assert_well_formed(out)
assert isinstance(out[-1], RunFinishedEvent)
# The tool call appears in two add_message events but is emitted exactly once.
assert len([e for e in out if isinstance(e, ToolCallStartEvent)]) == 1
assert len([e for e in out if isinstance(e, ToolCallResultEvent)]) == 1
# The streamed message has exactly one START/END pair.
assert len([e for e in out if isinstance(e, TextMessageStartEvent)]) == 1
assert len([e for e in out if isinstance(e, TextMessageEndEvent)]) == 1
# Every node ran: three STEP_FINISHED events.
assert len([e for e in out if isinstance(e, StepFinishedEvent)]) == 3
def test_full_sequence_ending_in_error_is_well_formed():
"""A run that errors mid-stream still closes the open message and ends in RUN_ERROR."""
t = AGUITranslator(run_id="run-2", thread_id="sess-2")
sequence = [
("vertices_sorted", {"ids": ["ChatInput-a"], "to_run": ["ChatInput-a", "Agent-b"]}),
("token", {"chunk": "partial answer", "id": "m1"}),
("error", {"text": "the agent crashed"}),
]
out = _run_sequence(t, sequence)
_assert_well_formed(out)
assert isinstance(out[-1], RunErrorEvent)
assert "the agent crashed" in out[-1].message

View File

@ -4,7 +4,7 @@ This test module provides extensive coverage of the converter functions that
transform between V2 workflow schemas and V1 schemas. Tests include:
Test Coverage:
- Input parsing and transformation (parse_flat_inputs)
- Native body parsing (parse_workflow_run_request)
- Nested value extraction from various data structures
- Text extraction from different message formats
- Model source and file path extraction
@ -37,13 +37,11 @@ from langflow.api.v2.converters import (
_simplify_output_content,
create_error_response,
create_job_response,
parse_flat_inputs,
run_response_to_workflow_response,
)
from lfx.schema.workflow import (
ErrorDetail,
JobStatus,
WorkflowExecutionRequest,
WorkflowExecutionResponse,
WorkflowJobResponse,
)
@ -60,194 +58,6 @@ def _setup_graph_get_vertex(graph: Mock, vertices: list[Mock]) -> None:
graph.get_vertex = Mock(side_effect=lambda vid: vertex_map.get(vid))
class TestParseFlatInputs:
"""Test suite for parse_flat_inputs function."""
def test_parse_flat_inputs_basic(self):
"""Test basic flat input parsing with component_id.param format."""
inputs = {
"ChatInput-abc.input_value": "hello",
"LLM-xyz.temperature": 0.7,
}
tweaks, session_id = parse_flat_inputs(inputs)
assert tweaks == {
"ChatInput-abc": {"input_value": "hello"},
"LLM-xyz": {"temperature": 0.7},
}
assert session_id is None
def test_parse_flat_inputs_with_session_id(self):
"""Test parsing with session_id extraction."""
inputs = {
"ChatInput-abc.input_value": "hello",
"ChatInput-abc.session_id": "session-123",
"LLM-xyz.temperature": 0.7,
}
tweaks, session_id = parse_flat_inputs(inputs)
assert tweaks == {
"ChatInput-abc": {"input_value": "hello", "session_id": "session-123"},
"LLM-xyz": {"temperature": 0.7},
}
assert session_id == "session-123"
def test_parse_flat_inputs_multiple_session_ids(self):
"""Test that first session_id is used when multiple are provided."""
inputs = {
"ChatInput-abc.session_id": "session-first",
"ChatInput-xyz.session_id": "session-second",
}
tweaks, session_id = parse_flat_inputs(inputs)
assert session_id == "session-first"
assert tweaks == {
"ChatInput-abc": {"session_id": "session-first"},
"ChatInput-xyz": {"session_id": "session-second"},
}
def test_parse_flat_inputs_dict_values(self):
"""Test backward compatibility with dict values (no dot notation)."""
inputs = {
"ChatInput-abc": {"input_value": "hello", "temperature": 0.5},
}
tweaks, session_id = parse_flat_inputs(inputs)
assert tweaks == {"ChatInput-abc": {"input_value": "hello", "temperature": 0.5}}
assert session_id is None
def test_parse_flat_inputs_mixed_formats(self):
"""Test mixed flat and dict formats."""
inputs = {
"ChatInput-abc.input_value": "hello",
"LLM-xyz": {"temperature": 0.7, "max_tokens": 100},
}
tweaks, session_id = parse_flat_inputs(inputs)
assert tweaks == {
"ChatInput-abc": {"input_value": "hello"},
"LLM-xyz": {"temperature": 0.7, "max_tokens": 100},
}
assert session_id is None
def test_parse_flat_inputs_empty(self):
"""Test with empty inputs."""
tweaks, session_id = parse_flat_inputs({})
assert tweaks == {}
assert session_id is None
def test_parse_flat_inputs_multiple_params_same_component(self):
"""Test multiple parameters for the same component."""
inputs = {
"LLM-xyz.temperature": 0.7,
"LLM-xyz.max_tokens": 100,
"LLM-xyz.top_p": 0.9,
}
tweaks, session_id = parse_flat_inputs(inputs)
assert tweaks == {
"LLM-xyz": {
"temperature": 0.7,
"max_tokens": 100,
"top_p": 0.9,
}
}
assert session_id is None
def test_parse_flat_inputs_malformed_key_no_dot(self):
"""Test handling of non-dict value without dot notation (edge case)."""
# Non-dict values without dots are ignored (not valid component.param format)
inputs = {
"ChatInput-abc.input_value": "hello",
"invalid_key_no_dot": "should be ignored",
"LLM-xyz.temperature": 0.7,
}
tweaks, session_id = parse_flat_inputs(inputs)
# Only valid dot-notation keys should be parsed
assert tweaks == {
"ChatInput-abc": {"input_value": "hello"},
"LLM-xyz": {"temperature": 0.7},
}
assert session_id is None
def test_parse_flat_inputs_null_values(self):
"""Test handling of None/null values in inputs."""
inputs = {
"ChatInput-abc.input_value": None,
"LLM-xyz.temperature": 0.7,
"DataNode-123.data": None,
}
tweaks, session_id = parse_flat_inputs(inputs)
# None values should be preserved
assert tweaks == {
"ChatInput-abc": {"input_value": None},
"LLM-xyz": {"temperature": 0.7},
"DataNode-123": {"data": None},
}
assert session_id is None
def test_parse_flat_inputs_deeply_nested_dict(self):
"""Test handling of deeply nested dict structures (backward compatibility)."""
inputs = {"Component-abc": {"level1": {"level2": {"level3": {"value": "deeply nested"}}}}}
tweaks, session_id = parse_flat_inputs(inputs)
# Deeply nested dicts should be preserved as-is
assert tweaks == {"Component-abc": {"level1": {"level2": {"level3": {"value": "deeply nested"}}}}}
assert session_id is None
def test_parse_flat_inputs_empty_collections(self):
"""Test handling of empty lists and dicts as values."""
inputs = {
"Component-1.list_param": [],
"Component-2.dict_param": {},
"Component-3.string_param": "",
}
tweaks, session_id = parse_flat_inputs(inputs)
assert tweaks == {
"Component-1": {"list_param": []},
"Component-2": {"dict_param": {}},
"Component-3": {"string_param": ""},
}
assert session_id is None
def test_parse_flat_inputs_explicit_none_values(self):
"""Test explicit None checks - None should be preserved, not treated as missing."""
inputs = {
"Component-1.optional_param": None,
"Component-2.required_param": "value",
"Component-3.another_none": None,
}
tweaks, session_id = parse_flat_inputs(inputs)
# None values should be explicitly preserved
assert tweaks == {
"Component-1": {"optional_param": None},
"Component-2": {"required_param": "value"},
"Component-3": {"another_none": None},
}
assert session_id is None
def test_parse_flat_inputs_special_characters_in_keys(self):
"""Test handling of special characters (dashes, underscores) in component IDs and parameter names."""
inputs = {
"Component-with-dashes.param_with_underscores": "value1",
"Component_123.param-456": "value2",
"Component_With_Underscores.param_name": "value3",
}
tweaks, session_id = parse_flat_inputs(inputs)
assert tweaks == {
"Component-with-dashes": {"param_with_underscores": "value1"},
"Component_123": {"param-456": "value2"},
"Component_With_Underscores": {"param_name": "value3"},
}
assert session_id is None
class TestExtractNestedValue:
"""Test suite for _extract_nested_value helper function with realistic payload structures."""
@ -863,10 +673,10 @@ class TestCreateErrorResponse:
"""Test error response structure."""
flow_id = "flow-123"
job_id = uuid4()
request = WorkflowExecutionRequest(flow_id=flow_id, inputs={"test": "input"})
request_inputs = {"test": "input"}
error = ValueError("Test error message")
response = create_error_response(flow_id, str(job_id), request, error)
response = create_error_response(flow_id, str(job_id), request_inputs, error)
assert isinstance(response, WorkflowExecutionResponse)
assert response.flow_id == flow_id
@ -879,7 +689,7 @@ class TestCreateErrorResponse:
"""Test error details in response."""
error = RuntimeError("Runtime error occurred")
job_id = str(uuid4())
response = create_error_response("flow-1", job_id, WorkflowExecutionRequest(flow_id="flow-1", inputs={}), error)
response = create_error_response("flow-1", job_id, {}, error)
error_detail = response.errors[0]
assert isinstance(error_detail, ErrorDetail)
@ -891,19 +701,18 @@ class TestCreateErrorResponse:
def test_create_error_response_preserves_inputs(self):
"""Test that original inputs are preserved in error response."""
inputs = {"component.param": "value"}
request = WorkflowExecutionRequest(flow_id="flow-1", inputs=inputs)
request_inputs = inputs
error = Exception("Error")
response = create_error_response("flow-1", str(uuid4()), request, error)
response = create_error_response("flow-1", str(uuid4()), request_inputs, error)
assert response.inputs == inputs
def test_create_error_response_preserves_globals(self):
"""Test that body globals are preserved in error response."""
globals_ = {"FILENAME": "relatório—final.pdf", "OWNER_NAME": "José"}
request = WorkflowExecutionRequest(flow_id="flow-1", inputs={}, globals=globals_)
error = Exception("Error")
response = create_error_response("flow-1", str(uuid4()), request, error)
response = create_error_response("flow-1", str(uuid4()), {}, error, effective_globals=globals_)
assert response.globals == globals_
@ -937,11 +746,11 @@ class TestRunResponseToWorkflowResponse:
run_response.outputs = [run_output]
# Create request
request = WorkflowExecutionRequest(flow_id="flow-123", inputs={"test": "input"})
request_inputs = {"test": "input"}
# Convert
job_id = uuid4()
response = run_response_to_workflow_response(run_response, "flow-123", str(job_id), request, graph)
response = run_response_to_workflow_response(run_response, "flow-123", str(job_id), request_inputs, graph)
assert isinstance(response, WorkflowExecutionResponse)
assert response.flow_id == "flow-123"
@ -960,9 +769,10 @@ class TestRunResponseToWorkflowResponse:
run_response.outputs = []
globals_ = {"FILENAME": "relatório—final.pdf", "OWNER_NAME": "José"}
request = WorkflowExecutionRequest(flow_id="flow-123", inputs={}, globals=globals_)
response = run_response_to_workflow_response(run_response, "flow-123", str(uuid4()), request, graph)
response = run_response_to_workflow_response(
run_response, "flow-123", str(uuid4()), {}, graph, effective_globals=globals_
)
assert response.globals == globals_
@ -992,10 +802,10 @@ class TestRunResponseToWorkflowResponse:
run_output.outputs = [result_data]
run_response.outputs = [run_output]
request = WorkflowExecutionRequest(flow_id="flow-1", inputs={})
request_inputs = {}
job_id = str(uuid4())
response = run_response_to_workflow_response(run_response, "flow-1", job_id, request, graph)
response = run_response_to_workflow_response(run_response, "flow-1", job_id, request_inputs, graph)
assert "llm-123" in response.outputs
output = response.outputs["llm-123"]
@ -1028,10 +838,10 @@ class TestRunResponseToWorkflowResponse:
run_response = Mock()
run_response.outputs = []
request = WorkflowExecutionRequest(flow_id="flow-1", inputs={})
request_inputs = {}
job_id = str(uuid4())
response = run_response_to_workflow_response(run_response, "flow-1", job_id, request, graph)
response = run_response_to_workflow_response(run_response, "flow-1", job_id, request_inputs, graph)
# Should use IDs instead of duplicate display names
assert "output-1" in response.outputs
@ -1065,10 +875,10 @@ class TestRunResponseToWorkflowResponse:
run_output.outputs = [result_data]
run_response.outputs = [run_output]
request = WorkflowExecutionRequest(flow_id="flow-1", inputs={})
request_inputs = {}
job_id = str(uuid4())
response = run_response_to_workflow_response(run_response, "flow-1", job_id, request, graph)
response = run_response_to_workflow_response(run_response, "flow-1", job_id, request_inputs, graph)
# Data type non-output nodes should show content
assert response.outputs["data-123"].content == {"result": "42"}
@ -1092,10 +902,10 @@ class TestRunResponseToWorkflowResponse:
run_response = Mock()
run_response.outputs = []
request = WorkflowExecutionRequest(flow_id="flow-1", inputs={})
request_inputs = {}
job_id = str(uuid4())
response = run_response_to_workflow_response(run_response, "flow-1", job_id, request, graph)
response = run_response_to_workflow_response(run_response, "flow-1", job_id, request_inputs, graph)
assert "output-123" in response.outputs
@ -1110,10 +920,10 @@ class TestRunResponseToWorkflowResponse:
run_response.outputs = []
inputs = {"component.param": "value"}
request = WorkflowExecutionRequest(flow_id="flow-1", inputs=inputs)
request_inputs = inputs
job_id = str(uuid4())
response = run_response_to_workflow_response(run_response, "flow-1", job_id, request, graph)
response = run_response_to_workflow_response(run_response, "flow-1", job_id, request_inputs, graph)
assert response.inputs == inputs
def test_run_response_vector_store_terminal(self):
@ -1140,10 +950,10 @@ class TestRunResponseToWorkflowResponse:
run_output.outputs = [result_data]
run_response.outputs = [run_output]
request = WorkflowExecutionRequest(flow_id="flow-1", inputs={})
request_inputs = {}
job_id = str(uuid4())
response = run_response_to_workflow_response(run_response, "flow-1", job_id, request, graph)
response = run_response_to_workflow_response(run_response, "flow-1", job_id, request_inputs, graph)
# Data type non-output nodes should show content
assert "pinecone-123" in response.outputs
@ -1176,10 +986,10 @@ class TestRunResponseToWorkflowResponse:
run_output.outputs = [result_data]
run_response.outputs = [run_output]
request = WorkflowExecutionRequest(flow_id="flow-1", inputs={})
request_inputs = {}
job_id = str(uuid4())
response = run_response_to_workflow_response(run_response, "flow-1", job_id, request, graph)
response = run_response_to_workflow_response(run_response, "flow-1", job_id, request_inputs, graph)
# Should include content and metadata
assert "retriever-456" in response.outputs
@ -1201,10 +1011,10 @@ class TestRunResponseToWorkflowResponse:
run_response = Mock()
run_response.outputs = None
request = WorkflowExecutionRequest(flow_id="flow-1", inputs={})
request_inputs = {}
job_id = str(uuid4())
response = run_response_to_workflow_response(run_response, "flow-1", job_id, request, graph)
response = run_response_to_workflow_response(run_response, "flow-1", job_id, request_inputs, graph)
assert response.outputs == {}
assert response.status == JobStatus.COMPLETED
@ -1228,11 +1038,11 @@ class TestRunResponseToWorkflowResponse:
run_response = Mock()
run_response.outputs = []
request = WorkflowExecutionRequest(flow_id="flow-1", inputs={})
request_inputs = {}
# Should handle gracefully without crashing
job_id = str(uuid4())
response = run_response_to_workflow_response(run_response, "flow-1", job_id, request, graph)
response = run_response_to_workflow_response(run_response, "flow-1", job_id, request_inputs, graph)
# Should use ID as fallback when display_name is None
assert "corrupted-123" in response.outputs
@ -1263,16 +1073,77 @@ class TestRunResponseToWorkflowResponse:
run_output.outputs = [result_data]
run_response.outputs = [run_output]
request = WorkflowExecutionRequest(flow_id="flow-1", inputs={})
request_inputs = {}
# Should handle gracefully - vertex won't match result_data
job_id = str(uuid4())
response = run_response_to_workflow_response(run_response, "flow-1", job_id, request, graph)
response = run_response_to_workflow_response(run_response, "flow-1", job_id, request_inputs, graph)
# Output should exist but with no content (no matching result_data)
assert "output-123" in response.outputs
assert response.outputs["output-123"].content is None
_VALID_UUID = "67ccd2be-17f0-8190-81ff-3bb2cf6508e6"
class TestParseWorkflowRunRequest:
"""``parse_workflow_run_request`` projects ``WorkflowRunRequest`` onto ``ParsedWorkflowRun``."""
def test_minimal_body_round_trips_with_defaults(self):
from langflow.api.v2.converters import parse_workflow_run_request
from lfx.schema.workflow import WorkflowRunRequest
parsed = parse_workflow_run_request(WorkflowRunRequest(flow_id=_VALID_UUID))
assert parsed.flow_id == _VALID_UUID
assert parsed.tweaks == {}
assert parsed.input_value == ""
assert parsed.session_id is None
assert parsed.run_id is None
assert parsed.mode == "sync"
assert parsed.start_component_id is None
assert parsed.stop_component_id is None
assert parsed.data is None
assert parsed.files is None
def test_full_body_round_trip(self):
from langflow.api.v2.converters import parse_workflow_run_request
from lfx.schema.workflow import WorkflowMode, WorkflowRunRequest
request = WorkflowRunRequest(
flow_id=_VALID_UUID,
input_value="hello",
tweaks={"ChatInput-abc": {"some_param": "v"}},
session_id="session-123",
mode=WorkflowMode.STREAM,
stream_protocol="agui",
data={"nodes": [], "edges": []},
files=["a.txt", "b.png"],
start_component_id="ChatInput-abc",
stop_component_id="ChatOutput-xyz",
)
parsed = parse_workflow_run_request(request)
assert parsed.flow_id == _VALID_UUID
assert parsed.tweaks == {"ChatInput-abc": {"some_param": "v"}}
assert parsed.input_value == "hello"
assert parsed.session_id == "session-123"
assert parsed.mode == "stream"
assert parsed.start_component_id == "ChatInput-abc"
assert parsed.stop_component_id == "ChatOutput-xyz"
assert parsed.data == {"nodes": [], "edges": []}
assert parsed.files == ["a.txt", "b.png"]
def test_run_id_is_always_none_on_the_parsed_record(self):
"""The endpoint generates run_id; callers cannot supply it via the body."""
from langflow.api.v2.converters import parse_workflow_run_request
from lfx.schema.workflow import WorkflowRunRequest
parsed = parse_workflow_run_request(WorkflowRunRequest(flow_id=_VALID_UUID))
assert parsed.run_id is None
if __name__ == "__main__":
pytest.main([__file__, "-v"])

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,381 @@
"""Security tests for ``POST /api/v2/workflows/public``.
Mirrors the v1 ``build_public_tmp`` security suite. Each test pins one of
the mitigations the v2 public endpoint is supposed to inherit from v1:
- access_type == PUBLIC gate (403 for private flows).
- per-visitor virtual_flow_id propagated to the graph builder.
- session id namespaced under the visitor's virtual flow id
(CVE-2026-33017).
- file-path validation (GHSA-rcjh-r59h-gq37).
- ``data`` / ``tweaks`` rejected by the wire schema (visitors must never
override the stored flow definition).
- AUTO_LOGIN parity: authenticated_user_id is ignored when AUTO_LOGIN is
on so the backend's virtual_flow_id matches the frontend's.
- Owner impersonation: the flow runs under the flow owner's user, not
the visitor's.
"""
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import pytest
from httpx import AsyncClient, codes
from langflow.services.database.models.flow.model import Flow
from lfx.services.deps import session_scope
from sqlmodel import select
if TYPE_CHECKING:
from uuid import UUID
def _stub_generate_flow_events(monkeypatch, captured: dict) -> None:
"""Capture kwargs that would reach ``generate_flow_events`` without running anything.
The v2 public endpoint reaches ``generate_flow_events`` via
``_stream_event_frames``; intercepting at the build entry point lets
us assert on flow_id translation, session scoping, owner
impersonation, and source_flow_id propagation without needing the
full streaming pipeline.
"""
import langflow.api.v2.workflow as workflow_module
async def _fake_generate_flow_events(**kwargs: Any) -> None:
captured.update(kwargs)
# Make sure the stream loop's queue terminates cleanly.
import time
await kwargs["event_manager"].queue.put((None, None, time.time()))
monkeypatch.setattr(workflow_module, "generate_flow_events", _fake_generate_flow_events)
def _send_unauthenticated(client: AsyncClient, client_id: str) -> None:
"""Drop login cookies and set the public ``client_id`` cookie.
The shared client persists access-token cookies from
``logged_in_headers``; clearing them is the only way to land in the
AUTO_LOGIN=true unauthenticated namespace path the CVE targets.
"""
client.cookies.clear()
client.cookies.set("client_id", client_id)
async def _make_flow_public(client: AsyncClient, flow_id: UUID, headers: dict) -> None:
response = await client.patch(
f"api/v1/flows/{flow_id}",
json={"access_type": "PUBLIC"},
headers=headers,
)
assert response.status_code == codes.OK
async def _read_stream(response) -> None:
"""Drain the SSE response so the generator runs and the stub fires."""
async for _ in response.aiter_bytes():
pass
@pytest.fixture
async def public_flow_id(client: AsyncClient, json_memory_chatbot_no_llm, logged_in_headers):
from tests.unit.build_utils import create_flow
flow_id = await create_flow(client, json_memory_chatbot_no_llm, logged_in_headers)
await _make_flow_public(client, flow_id, logged_in_headers)
return flow_id
@pytest.mark.benchmark
@pytest.mark.security
async def test_public_endpoint_rejects_non_public_flow(
client: AsyncClient, json_memory_chatbot_no_llm, logged_in_headers
):
"""Private flows return 403 before any policy validation runs (info-leak guard)."""
from tests.unit.build_utils import create_flow
flow_id = await create_flow(client, json_memory_chatbot_no_llm, logged_in_headers)
# Note: we deliberately do NOT mark it public.
_send_unauthenticated(client, "private-test-client")
response = await client.post(
"api/v2/workflows/public",
json={"flow_id": str(flow_id), "input_value": "Hi"},
headers={"Content-Type": "application/json"},
)
assert response.status_code == codes.FORBIDDEN
@pytest.mark.benchmark
@pytest.mark.security
async def test_public_endpoint_rejects_data_field(client: AsyncClient, public_flow_id):
"""``data`` is forbidden by the wire schema — visitors cannot override stored flow data."""
_send_unauthenticated(client, "data-rejection-client")
response = await client.post(
"api/v2/workflows/public",
json={
"flow_id": str(public_flow_id),
"input_value": "Hi",
"data": {"nodes": [], "edges": []},
},
headers={"Content-Type": "application/json"},
)
assert response.status_code == codes.UNPROCESSABLE_ENTITY
@pytest.mark.benchmark
@pytest.mark.security
async def test_public_endpoint_rejects_tweaks_field(client: AsyncClient, public_flow_id):
"""``tweaks`` is forbidden by the wire schema — visitors cannot override component params."""
_send_unauthenticated(client, "tweaks-rejection-client")
response = await client.post(
"api/v2/workflows/public",
json={
"flow_id": str(public_flow_id),
"input_value": "Hi",
"tweaks": {"node-id": {"input_value": "override"}},
},
headers={"Content-Type": "application/json"},
)
assert response.status_code == codes.UNPROCESSABLE_ENTITY
@pytest.mark.benchmark
@pytest.mark.security
async def test_public_endpoint_rejects_non_stream_mode(client: AsyncClient, public_flow_id):
"""``mode`` must be ``stream`` — sync/background widen the public attack surface."""
_send_unauthenticated(client, "mode-rejection-client")
response = await client.post(
"api/v2/workflows/public",
json={
"flow_id": str(public_flow_id),
"input_value": "Hi",
"mode": "sync",
},
headers={"Content-Type": "application/json"},
)
assert response.status_code == codes.UNPROCESSABLE_ENTITY
@pytest.mark.benchmark
@pytest.mark.security
async def test_public_endpoint_rejects_malicious_files(client: AsyncClient, public_flow_id):
"""Regression for GHSA-rcjh-r59h-gq37 — file paths must be ``{flow_id}/{basename}``."""
_send_unauthenticated(client, "files-rejection-client")
response = await client.post(
"api/v2/workflows/public",
json={
"flow_id": str(public_flow_id),
"input_value": "Hi",
"files": ["../../../etc/passwd"],
},
headers={"Content-Type": "application/json"},
)
assert response.status_code == codes.BAD_REQUEST
@pytest.mark.benchmark
@pytest.mark.security
async def test_public_endpoint_namespaces_caller_session(client: AsyncClient, public_flow_id, monkeypatch):
"""Caller-supplied ``session_id`` is wrapped under the (client_id, flow_id) namespace.
Pins CVE-2026-33017 for the v2 public endpoint: an unauthenticated
visitor must not be able to address a session that lives outside
their own namespace through a Memory component.
"""
from langflow.api.utils.flow_utils import compute_virtual_flow_id
captured: dict = {}
_stub_generate_flow_events(monkeypatch, captured)
client_id = "ns-test-client-v2"
_send_unauthenticated(client, client_id)
victim_session = str(public_flow_id)
async with client.stream(
"POST",
"api/v2/workflows/public",
json={
"flow_id": str(public_flow_id),
"input_value": "Hi",
"session_id": victim_session,
},
headers={"Content-Type": "application/json"},
) as response:
assert response.status_code == codes.OK
await _read_stream(response)
expected_namespace = str(compute_virtual_flow_id(client_id, public_flow_id))
sent_inputs = captured["inputs"]
assert sent_inputs is not None
assert sent_inputs.session == f"{expected_namespace}:{victim_session}"
assert sent_inputs.session != victim_session
@pytest.mark.benchmark
@pytest.mark.security
async def test_public_endpoint_uses_virtual_flow_id_for_storage(client: AsyncClient, public_flow_id, monkeypatch):
"""``generate_flow_events`` is called with ``flow_id=virtual``, ``source_flow_id=real``.
The graph loads from the real flow id (the DB row) but tags messages
with the virtual flow id so the popup's ``useGetFlowId``-keyed
filter actually matches.
"""
from langflow.api.utils.flow_utils import compute_virtual_flow_id
captured: dict = {}
_stub_generate_flow_events(monkeypatch, captured)
client_id = "virtual-id-test-client"
_send_unauthenticated(client, client_id)
async with client.stream(
"POST",
"api/v2/workflows/public",
json={"flow_id": str(public_flow_id), "input_value": "Hi"},
headers={"Content-Type": "application/json"},
) as response:
assert response.status_code == codes.OK
await _read_stream(response)
expected_virtual = compute_virtual_flow_id(client_id, public_flow_id)
assert captured["flow_id"] == expected_virtual
assert captured["source_flow_id"] == public_flow_id
@pytest.mark.benchmark
@pytest.mark.security
async def test_public_endpoint_isolates_disjoint_clients(client: AsyncClient, public_flow_id, monkeypatch):
"""Two client_ids submitting the same session string land in disjoint namespaces."""
shared_session = "shared-session-name"
captured: dict = {}
_stub_generate_flow_events(monkeypatch, captured)
_send_unauthenticated(client, "iso-client-A")
async with client.stream(
"POST",
"api/v2/workflows/public",
json={"flow_id": str(public_flow_id), "input_value": "Hi", "session_id": shared_session},
headers={"Content-Type": "application/json"},
) as response:
assert response.status_code == codes.OK
await _read_stream(response)
session_a = captured["inputs"].session
captured.clear()
_send_unauthenticated(client, "iso-client-B")
async with client.stream(
"POST",
"api/v2/workflows/public",
json={"flow_id": str(public_flow_id), "input_value": "Hi", "session_id": shared_session},
headers={"Content-Type": "application/json"},
) as response:
assert response.status_code == codes.OK
await _read_stream(response)
session_b = captured["inputs"].session
assert session_a != session_b
assert session_a.endswith(f":{shared_session}")
assert session_b.endswith(f":{shared_session}")
@pytest.mark.benchmark
@pytest.mark.security
async def test_public_endpoint_runs_as_flow_owner(client: AsyncClient, public_flow_id, monkeypatch):
"""Owner impersonation: ``current_user`` passed to the build is the flow's owner."""
captured: dict = {}
_stub_generate_flow_events(monkeypatch, captured)
_send_unauthenticated(client, "owner-test-client")
async with client.stream(
"POST",
"api/v2/workflows/public",
json={"flow_id": str(public_flow_id), "input_value": "Hi"},
headers={"Content-Type": "application/json"},
) as response:
assert response.status_code == codes.OK
await _read_stream(response)
# Look up the flow owner directly to compare.
async with session_scope() as session:
flow = (await session.exec(select(Flow).where(Flow.id == public_flow_id))).first()
assert flow is not None
owner_id = flow.user_id
assert captured["current_user"].id == owner_id
@pytest.mark.benchmark
@pytest.mark.security
async def test_public_endpoint_rejects_missing_client_id(client: AsyncClient, public_flow_id):
"""Without a ``client_id`` cookie or authenticated user, the request is rejected."""
client.cookies.clear() # no client_id, no auth
response = await client.post(
"api/v2/workflows/public",
json={"flow_id": str(public_flow_id), "input_value": "Hi"},
headers={"Content-Type": "application/json"},
)
assert response.status_code == codes.BAD_REQUEST
@pytest.mark.benchmark
@pytest.mark.security
async def test_public_endpoint_sanitizes_component_validation_error(client: AsyncClient, public_flow_id, monkeypatch):
"""``CustomComponentValidationError`` must not leak blocked class names to anonymous visitors.
Mirrors v1 ``build_public_tmp``: the raw error message embeds the
disabled component class names, which is enumeration of the owner's
flow internals through a public surface. Surface a sanitized 400.
"""
from lfx.utils.flow_validation import CustomComponentValidationError
raw_message = "Flow build blocked: custom components are not allowed: SecretInternalComponent"
def _raise(*_args, **_kwargs):
raise CustomComponentValidationError(raw_message)
import langflow.api.v2.workflow_public as workflow_public_module
monkeypatch.setattr(workflow_public_module, "validate_flow_for_current_settings", _raise)
_send_unauthenticated(client, "component-validation-client")
response = await client.post(
"api/v2/workflows/public",
json={"flow_id": str(public_flow_id), "input_value": "Hi"},
headers={"Content-Type": "application/json"},
)
assert response.status_code == codes.BAD_REQUEST
detail = response.json().get("detail", "")
assert detail == "This flow cannot be executed."
assert "SecretInternalComponent" not in detail
assert raw_message not in response.text
@pytest.mark.benchmark
@pytest.mark.security
async def test_public_endpoint_surfaces_value_error_as_400(client: AsyncClient, public_flow_id, monkeypatch):
"""Other ``ValueError``s from the gate sequence become 400 with the message preserved.
Mirrors v1 ``build_public_tmp``'s ``except ValueError -> HTTP 400``.
Without the wrapper the same path returns a 500 with a stack trace.
"""
import langflow.api.v2.workflow_public as workflow_public_module
gate_error_message = "custom gate failure"
def _raise(*_args, **_kwargs):
raise ValueError(gate_error_message)
monkeypatch.setattr(workflow_public_module, "validate_flow_for_current_settings", _raise)
_send_unauthenticated(client, "value-error-client")
response = await client.post(
"api/v2/workflows/public",
json={"flow_id": str(public_flow_id), "input_value": "Hi"},
headers={"Content-Type": "application/json"},
)
assert response.status_code == codes.BAD_REQUEST
assert response.json().get("detail") == "custom gate failure"

View File

@ -8,6 +8,7 @@
"name": "langflow",
"version": "1.10.0",
"dependencies": {
"@ag-ui/client": "0.0.53",
"@chakra-ui/number-input": "^2.1.2",
"@chakra-ui/system": "^2.6.2",
"@emotion/react": "^11.14.0",
@ -154,6 +155,69 @@
"dev": true,
"license": "MIT"
},
"node_modules/@ag-ui/client": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/client/-/client-0.0.53.tgz",
"integrity": "sha512-Mkup36KUp0KXy9v89QtAOWDUoh8H1s1Vgl4zvQv9HqXuAK1TkbtpXJHpbgZJXIxTqd54KT6yCurmC2UkOP7FDQ==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@ag-ui/encoder": "0.0.53",
"@ag-ui/proto": "0.0.53",
"@types/uuid": "^10.0.0",
"compare-versions": "^6.1.1",
"fast-json-patch": "^3.1.1",
"rxjs": "7.8.1",
"untruncate-json": "^0.0.1",
"uuid": "^11.1.0",
"zod": "^3.22.4"
}
},
"node_modules/@ag-ui/client/node_modules/@types/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==",
"license": "MIT"
},
"node_modules/@ag-ui/client/node_modules/uuid": {
"version": "11.1.1",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.1.tgz",
"integrity": "sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/esm/bin/uuid"
}
},
"node_modules/@ag-ui/core": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/core/-/core-0.0.53.tgz",
"integrity": "sha512-11UocR7fFdMWw503bWCX2IOK15vbWfxT11Mn9xOiPBVO/UVcn57ywGrlLL4UaBlPgmUTvuzr2yYR2ElSqiN2wQ==",
"dependencies": {
"zod": "^3.22.4"
}
},
"node_modules/@ag-ui/encoder": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/encoder/-/encoder-0.0.53.tgz",
"integrity": "sha512-bAOcfVdm6U4H6G6tW+DZfwPEQm1w/snVBTwaFn9nJcEMW69M7/HZuwvEc/7Zo0rK1jRL32N/j60PwTAeky19fw==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@ag-ui/proto": "0.0.53"
}
},
"node_modules/@ag-ui/proto": {
"version": "0.0.53",
"resolved": "https://registry.npmjs.org/@ag-ui/proto/-/proto-0.0.53.tgz",
"integrity": "sha512-swjz22xWT8YUZt5OhmUwkARDQdwt8XM1hmGZbQrhRnNPXKwrKJX9ELlbnQ4iFUQIKkMWpphzE3vA3yNKs2bbKw==",
"dependencies": {
"@ag-ui/core": "0.0.53",
"@bufbuild/protobuf": "^2.2.5",
"@protobuf-ts/protoc": "^2.11.1"
}
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@ -883,6 +947,12 @@
"url": "https://github.com/sponsors/Borewit"
}
},
"node_modules/@bufbuild/protobuf": {
"version": "2.12.0",
"resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-2.12.0.tgz",
"integrity": "sha512-B/XlCaFIP8LOwzo+bz5uFzATYokcwCKQcghqnlfwSmM5eX/qTkvDBnDPs+gXtX/RyjxJ4DRikECcPJbyALA8FA==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@chakra-ui/anatomy": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/@chakra-ui/anatomy/-/anatomy-2.2.2.tgz",
@ -3624,6 +3694,15 @@
"node": ">=18"
}
},
"node_modules/@protobuf-ts/protoc": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@protobuf-ts/protoc/-/protoc-2.11.1.tgz",
"integrity": "sha512-mUZJaV0daGO6HUX90o/atzQ6A7bbN2RSuHtdwo8SSF2Qoe3zHwa4IHyCN1evftTeHfLmdz+45qo47sL+5P8nyg==",
"license": "Apache-2.0",
"bin": {
"protoc": "protoc.js"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@ -9157,6 +9236,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/compare-versions": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/compare-versions/-/compare-versions-6.1.1.tgz",
"integrity": "sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==",
"license": "MIT"
},
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@ -10368,6 +10453,12 @@
"node": ">=8.6.0"
}
},
"node_modules/fast-json-patch": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/fast-json-patch/-/fast-json-patch-3.1.1.tgz",
"integrity": "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ==",
"license": "MIT"
},
"node_modules/fast-json-stable-stringify": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
@ -17584,6 +17675,15 @@
"queue-microtask": "^1.2.2"
}
},
"node_modules/rxjs": {
"version": "7.8.1",
"resolved": "https://registry.npmjs.org/rxjs/-/rxjs-7.8.1.tgz",
"integrity": "sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.1.0"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@ -19430,6 +19530,12 @@
"@unrs/resolver-binding-win32-x64-msvc": "1.11.1"
}
},
"node_modules/untruncate-json": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/untruncate-json/-/untruncate-json-0.0.1.tgz",
"integrity": "sha512-4W9enDK4X1y1s2S/Rz7ysw6kDuMS3VmRjMFg7GZrNO+98OSe+x5Lh7PKYoVjy3lW/1wmhs6HW0lusnQRHgMarA==",
"license": "MIT"
},
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",

View File

@ -52,6 +52,7 @@
"@tabler/icons-react": "^3.6.0",
"@tailwindcss/forms": "^0.5.7",
"@tailwindcss/line-clamp": "^0.4.4",
"@ag-ui/client": "0.0.53",
"@tanstack/react-query": "^5.49.2",
"@types/axios": "^0.14.0",
"@types/mustache": "^4.2.6",

View File

@ -0,0 +1,285 @@
/**
* Tests for `applyStateDelta` — the JSON-Patch parser the bridge runs on
* every `STATE_DELTA` event. The regex (`^\/nodes\/([^/]+)$`), the
* add/replace gating, and the BUILDING fallback for unknown statuses are
* the bits a backend wire-shape change would silently break, so we pin
* them directly against the real flowStore singleton.
*/
import { BuildStatus } from "@/constants/enums";
import { applyStateDelta } from "@/controllers/API/agui/run-flow-bridge";
import useFlowStore from "@/stores/flowStore";
beforeEach(() => {
// Zustand singleton — only blow away the slices this helper touches so the
// rest of the store's action surface (updateBuildStatus, addDataToFlowPool)
// keeps working as written.
useFlowStore.setState({ flowBuildStatus: {}, flowPool: {} });
});
describe("applyStateDelta", () => {
it("adds the vertex build entry on op:add with success + output", () => {
const touched = new Set<string>();
applyStateDelta(
[
{
op: "add",
path: "/nodes/node-a",
value: {
status: "success",
output: { results: { text: "hi" } },
},
},
],
"run-1",
touched,
);
const state = useFlowStore.getState();
expect(state.flowBuildStatus["node-a"]?.status).toBe(BuildStatus.BUILT);
expect(state.flowPool["node-a"]).toHaveLength(1);
expect(state.flowPool["node-a"][0]).toMatchObject({
id: "node-a",
run_id: "run-1",
valid: true,
data: { results: { text: "hi" } },
});
expect(touched.has("node-a")).toBe(true);
});
it("treats op:replace the same as op:add", () => {
const touched = new Set<string>();
applyStateDelta(
[
{
op: "replace",
path: "/nodes/node-a",
value: {
status: "success",
output: { results: { text: "hi" } },
},
},
],
"run-1",
touched,
);
const state = useFlowStore.getState();
expect(state.flowBuildStatus["node-a"]?.status).toBe(BuildStatus.BUILT);
expect(state.flowPool["node-a"]).toHaveLength(1);
expect(touched.has("node-a")).toBe(true);
});
it("ignores op:remove", () => {
const touched = new Set<string>();
applyStateDelta(
[
{
op: "remove",
path: "/nodes/node-a",
value: { status: "success", output: {} },
},
],
"run-1",
touched,
);
const state = useFlowStore.getState();
expect(state.flowBuildStatus["node-a"]).toBeUndefined();
expect(state.flowPool["node-a"]).toBeUndefined();
expect(touched.size).toBe(0);
});
it("ignores nested paths beyond /nodes/<id>", () => {
const touched = new Set<string>();
applyStateDelta(
[
{
op: "add",
path: "/nodes/node-a/extra/path",
value: { status: "success", output: {} },
},
],
"run-1",
touched,
);
const state = useFlowStore.getState();
expect(state.flowBuildStatus["node-a"]).toBeUndefined();
expect(state.flowPool["node-a"]).toBeUndefined();
expect(touched.size).toBe(0);
});
it("ignores ops with null value", () => {
const touched = new Set<string>();
applyStateDelta(
[{ op: "add", path: "/nodes/node-a", value: null }],
"run-1",
touched,
);
const state = useFlowStore.getState();
expect(state.flowBuildStatus["node-a"]).toBeUndefined();
expect(state.flowPool["node-a"]).toBeUndefined();
expect(touched.size).toBe(0);
});
it("ignores ops with non-object value", () => {
const touched = new Set<string>();
applyStateDelta(
[{ op: "add", path: "/nodes/node-a", value: "string" }],
"run-1",
touched,
);
const state = useFlowStore.getState();
expect(state.flowBuildStatus["node-a"]).toBeUndefined();
expect(state.flowPool["node-a"]).toBeUndefined();
expect(touched.size).toBe(0);
});
it("updates build status to BUILDING for running with null output and skips the flow pool", () => {
const touched = new Set<string>();
applyStateDelta(
[
{
op: "add",
path: "/nodes/node-a",
value: { status: "running", output: null },
},
],
"run-1",
touched,
);
const state = useFlowStore.getState();
expect(state.flowBuildStatus["node-a"]?.status).toBe(BuildStatus.BUILDING);
expect(state.flowPool["node-a"]).toBeUndefined();
expect(touched.has("node-a")).toBe(true);
});
it("falls back to BuildStatus.BUILDING when the status is unknown", () => {
const touched = new Set<string>();
applyStateDelta(
[
{
op: "add",
path: "/nodes/node-a",
value: { status: "unknown_status", output: null },
},
],
"run-1",
touched,
);
const state = useFlowStore.getState();
expect(state.flowBuildStatus["node-a"]?.status).toBe(BuildStatus.BUILDING);
expect(touched.has("node-a")).toBe(true);
});
it("stamps the runId on each flow-pool entry", () => {
const touched = new Set<string>();
applyStateDelta(
[
{
op: "add",
path: "/nodes/node-a",
value: { status: "success", output: { results: {} } },
},
{
op: "add",
path: "/nodes/node-b",
value: { status: "error", output: { results: {} } },
},
],
"run-xyz",
touched,
);
const state = useFlowStore.getState();
expect(state.flowPool["node-a"][0].run_id).toBe("run-xyz");
expect(state.flowPool["node-b"][0].run_id).toBe("run-xyz");
// `valid` mirrors `status === "success"`.
expect(state.flowPool["node-a"][0].valid).toBe(true);
expect(state.flowPool["node-b"][0].valid).toBe(false);
});
describe("edge animation tracks node status", () => {
/**
* The v1 build path drove edge animation through onBuildStart/onBuildEnd.
* Without an explicit toggle here the AG-UI path used to leave edges
* static until ``finish()`` cleared them. Pin the contract: running flips
* edges on, success/error flips them off (per-node, no global resets).
*/
it("flips edges on when a node enters running status", () => {
const touched = new Set<string>();
const calls: Array<{ ids: string[]; running: boolean }> = [];
const original = useFlowStore.getState().updateEdgesRunningByNodes;
useFlowStore.setState({
updateEdgesRunningByNodes: (ids: string[], running: boolean) => {
calls.push({ ids, running });
},
});
applyStateDelta(
[
{
op: "add",
path: "/nodes/node-a",
value: { status: "running", output: null },
},
],
"run-1",
touched,
);
useFlowStore.setState({ updateEdgesRunningByNodes: original });
expect(calls).toEqual([{ ids: ["node-a"], running: true }]);
});
it("flips edges off when a node completes (success or error)", () => {
const touched = new Set<string>();
const calls: Array<{ ids: string[]; running: boolean }> = [];
const original = useFlowStore.getState().updateEdgesRunningByNodes;
useFlowStore.setState({
updateEdgesRunningByNodes: (ids: string[], running: boolean) => {
calls.push({ ids, running });
},
});
applyStateDelta(
[
{
op: "add",
path: "/nodes/node-a",
value: { status: "success", output: { results: {} } },
},
{
op: "add",
path: "/nodes/node-b",
value: { status: "error", output: null },
},
],
"run-1",
touched,
);
useFlowStore.setState({ updateEdgesRunningByNodes: original });
expect(calls).toEqual([
{ ids: ["node-a"], running: false },
{ ids: ["node-b"], running: false },
]);
});
});
});

View File

@ -0,0 +1,395 @@
import {
buildWorkflowRunRequest,
createWorkflowAgent,
WORKFLOWS_ENDPOINT,
WORKFLOWS_PUBLIC_ENDPOINT,
} from "../run-agent";
describe("buildWorkflowRunRequest", () => {
it("returns a native WorkflowRunRequest with input_value mapped from message", () => {
const body = buildWorkflowRunRequest({
flowId: "flow-1",
message: "hello",
});
expect(body).toEqual({
flow_id: "flow-1",
input_value: "hello",
mode: "stream",
stream_protocol: "agui",
});
});
it("defaults input_value to empty string when no message is provided", () => {
const body = buildWorkflowRunRequest({ flowId: "flow-1" });
expect(body.input_value).toBe("");
});
it("maps threadId to session_id only when explicitly provided", () => {
const withThread = buildWorkflowRunRequest({
flowId: "flow-1",
threadId: "t-9",
});
const without = buildWorkflowRunRequest({ flowId: "flow-1" });
expect(withThread.session_id).toBe("t-9");
expect(without).not.toHaveProperty("session_id");
});
it("drops an empty-string threadId (falsy guard pins current behavior)", () => {
// Semantic gap: the builder uses `if (opts.threadId)`, so an explicit
// `""` is indistinguishable from an unset value and session_id is
// omitted. This test pins that behavior; if we ever switch to
// `!== undefined` semantics, this assertion flips and the change is
// a deliberate one.
const body = buildWorkflowRunRequest({ flowId: "flow-1", threadId: "" });
expect(body).not.toHaveProperty("session_id");
});
it('forwards an explicit mode="stream" and pins stream_protocol', () => {
const body = buildWorkflowRunRequest({
flowId: "flow-1",
mode: "stream",
});
expect(body.mode).toBe("stream");
expect(body.stream_protocol).toBe("agui");
});
it("maps tweaks, partial-run ids, flowData and files into native keys", () => {
const body = buildWorkflowRunRequest({
flowId: "flow-1",
tweaks: { "ChatInput-abc": { input_value: "x" } },
startComponentId: "c1",
stopComponentId: "c2",
flowData: { nodes: [{ id: "n1" }], edges: [] },
files: ["a.txt"],
});
expect(body).toMatchObject({
tweaks: { "ChatInput-abc": { input_value: "x" } },
start_component_id: "c1",
stop_component_id: "c2",
data: { nodes: [{ id: "n1" }], edges: [] },
files: ["a.txt"],
});
});
it('rejects mode="sync" because the SSE decoder can\'t read a JSON response', () => {
expect(() =>
buildWorkflowRunRequest({ flowId: "flow-1", mode: "sync" }),
).toThrow(/only supports mode="stream"/);
});
it('rejects mode="background" because the response is a job JSON, not SSE', () => {
expect(() =>
buildWorkflowRunRequest({ flowId: "flow-1", mode: "background" }),
).toThrow(/only supports mode="stream"/);
});
it("omits optional native fields when the caller did not set them", () => {
const body = buildWorkflowRunRequest({ flowId: "flow-1" });
expect(body).not.toHaveProperty("tweaks");
expect(body).not.toHaveProperty("start_component_id");
expect(body).not.toHaveProperty("stop_component_id");
expect(body).not.toHaveProperty("data");
expect(body).not.toHaveProperty("files");
});
it("never emits AG-UI RunAgentInput keys on the request body", () => {
const body = buildWorkflowRunRequest({
flowId: "flow-1",
message: "hi",
threadId: "t-1",
tweaks: { x: { y: 1 } },
startComponentId: "s",
stopComponentId: "e",
flowData: { nodes: [], edges: [] },
files: ["f"],
});
for (const forbidden of [
"threadId",
"runId",
"forwardedProps",
"state",
"messages",
"tools",
"context",
]) {
expect(body).not.toHaveProperty(forbidden);
}
});
});
describe("createWorkflowAgent", () => {
it("defaults to the v2 workflows endpoint", () => {
const agent = createWorkflowAgent({
body: buildWorkflowRunRequest({ flowId: "flow-1" }),
});
expect(agent.url).toBe(WORKFLOWS_ENDPOINT);
});
it("uses a caller-provided url", () => {
const agent = createWorkflowAgent({
url: "/api/v2/workflows-test",
body: buildWorkflowRunRequest({ flowId: "flow-1" }),
});
expect(agent.url).toBe("/api/v2/workflows-test");
});
it("passes custom headers through to the underlying HttpAgent", () => {
const agent = createWorkflowAgent({
headers: { "X-Foo": "bar" },
body: buildWorkflowRunRequest({ flowId: "flow-1" }),
});
expect(agent.headers).toMatchObject({ "X-Foo": "bar" });
});
});
describe("createWorkflowAgent wire body", () => {
// jsdom does not ship `fetch`, `TextEncoder`, or `TextDecoder`; pull
// them from Node's `util` so we can spy on fetch and the AG-UI SSE
// decoder has the codecs it expects.
beforeAll(() => {
const g = global as {
fetch?: unknown;
TextEncoder?: unknown;
TextDecoder?: unknown;
};
const util = require("util") as {
TextEncoder: typeof TextEncoder;
TextDecoder: typeof TextDecoder;
};
if (typeof g.TextEncoder !== "function") g.TextEncoder = util.TextEncoder;
if (typeof g.TextDecoder !== "function") g.TextDecoder = util.TextDecoder;
if (typeof g.fetch !== "function") {
g.fetch = () => Promise.reject(new Error("fetch not stubbed"));
}
});
// AG-UI's `runHttpRequest` consumes `.ok`, `.status`, `.headers.get()`,
// and `.body.getReader()`; this fake response is the minimum surface
// that satisfies that contract so the SSE decoder runs for real.
function sseResponse(events: object[]): unknown {
const encoder = new (
globalThis as { TextEncoder: typeof TextEncoder }
).TextEncoder();
const payload = encoder.encode(
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join(""),
);
let delivered = false;
return {
ok: true,
status: 200,
headers: {
get(key: string) {
return key.toLowerCase() === "content-type"
? "text/event-stream"
: null;
},
},
body: {
getReader() {
return {
async read() {
if (delivered) return { done: true, value: undefined };
delivered = true;
return { done: false, value: payload };
},
async cancel() {
/* no-op */
},
};
},
},
text: async () => "",
};
}
it("posts the native WorkflowRunRequest body (not RunAgentInput) to the endpoint", async () => {
const captured: { url: string; init: RequestInit }[] = [];
const fetchImpl = async (input: RequestInfo | URL, init?: RequestInit) => {
captured.push({
url:
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: (input as Request).url,
init: init ?? {},
});
return sseResponse([
{ type: "RUN_STARTED", threadId: "t", runId: "r", timestamp: 0 },
{ type: "RUN_FINISHED", threadId: "t", runId: "r", timestamp: 0 },
]);
};
const fetchSpy = jest
.spyOn(global as { fetch: typeof fetch }, "fetch")
.mockImplementation(fetchImpl as unknown as typeof fetch);
try {
const body = buildWorkflowRunRequest({
flowId: "67ccd2be-17f0-8190-81ff-3bb2cf6508e6",
message: "hello",
threadId: "session-abc",
});
const agent = createWorkflowAgent({ body });
await new Promise<void>((resolve, reject) => {
const sub = agent
.run({
threadId: "session-abc",
runId: "",
state: {},
messages: [],
tools: [],
context: [],
forwardedProps: {},
})
.subscribe({
error: (err) => {
sub.unsubscribe();
reject(err);
},
complete: () => {
sub.unsubscribe();
resolve();
},
});
});
expect(captured).toHaveLength(1);
expect(captured[0].url).toBe(WORKFLOWS_ENDPOINT);
expect(captured[0].init.method).toBe("POST");
const wireBody = JSON.parse(captured[0].init.body as string);
expect(wireBody).toEqual({
flow_id: "67ccd2be-17f0-8190-81ff-3bb2cf6508e6",
input_value: "hello",
mode: "stream",
stream_protocol: "agui",
session_id: "session-abc",
});
} finally {
fetchSpy.mockRestore();
}
});
it("decodes typed AG-UI events from the SSE response", async () => {
const fetchSpy = jest
.spyOn(global as { fetch: typeof fetch }, "fetch")
.mockImplementation((async () =>
sseResponse([
{ type: "RUN_STARTED", threadId: "t", runId: "r", timestamp: 0 },
{
type: "CUSTOM",
name: "langflow.event",
value: { event_type: "token", data: { chunk: "hi" } },
timestamp: 0,
rawEvent: {},
},
{
type: "RUN_FINISHED",
threadId: "t",
runId: "r",
timestamp: 0,
},
])) as unknown as typeof fetch);
try {
const agent = createWorkflowAgent({
body: buildWorkflowRunRequest({
flowId: "67ccd2be-17f0-8190-81ff-3bb2cf6508e6",
message: "hi",
}),
});
const seenTypes: string[] = [];
await new Promise<void>((resolve, reject) => {
const sub = agent
.run({
threadId: "client-thread",
runId: "client-run",
state: {},
messages: [],
tools: [],
context: [],
forwardedProps: {},
})
.subscribe({
next: (e: { type: string }) => {
seenTypes.push(e.type);
},
error: (err) => {
sub.unsubscribe();
reject(err);
},
complete: () => {
sub.unsubscribe();
resolve();
},
});
});
expect(seenTypes).toEqual(
expect.arrayContaining(["RUN_STARTED", "CUSTOM", "RUN_FINISHED"]),
);
} finally {
fetchSpy.mockRestore();
}
});
});
describe("buildWorkflowRunRequest — public endpoint shape", () => {
// The public-flow endpoint's Pydantic schema has ``extra="forbid"``
// and explicitly omits ``data`` and ``tweaks``. Sending either would
// 422 the request. Pin the dropping behaviour here so a regression in
// the bridge surfaces in the unit suite, not as a CI playwright fail.
it("drops tweaks when usePublicEndpoint is true", () => {
const body = buildWorkflowRunRequest({
flowId: "11111111-1111-1111-1111-111111111111",
message: "hi",
tweaks: { "node-id": { input_value: "x" } },
usePublicEndpoint: true,
});
expect(body.tweaks).toBeUndefined();
});
it("drops flowData when usePublicEndpoint is true", () => {
const body = buildWorkflowRunRequest({
flowId: "11111111-1111-1111-1111-111111111111",
message: "hi",
flowData: { nodes: [], edges: [] },
usePublicEndpoint: true,
});
expect(body.data).toBeUndefined();
});
it("still includes tweaks/data when usePublicEndpoint is false (canvas path)", () => {
const body = buildWorkflowRunRequest({
flowId: "11111111-1111-1111-1111-111111111111",
message: "hi",
tweaks: { "node-id": { input_value: "x" } },
flowData: { nodes: [], edges: [] },
usePublicEndpoint: false,
});
expect(body.tweaks).toEqual({ "node-id": { input_value: "x" } });
expect(body.data).toEqual({ nodes: [], edges: [] });
});
});
describe("WORKFLOWS_PUBLIC_ENDPOINT", () => {
it("is the canonical /api/v2/workflows/public path", () => {
expect(WORKFLOWS_PUBLIC_ENDPOINT).toBe("/api/v2/workflows/public");
});
});

View File

@ -0,0 +1,333 @@
/**
* End-to-end tests for the AG-UI bridge: drive `runFlowAGUI` against a
* synthetic SSE stream and assert side effects on the real `flowStore` +
* `alertStore` singletons. The unit tests in `run-flow-bridge.test.ts`
* only cover the event-dispatch return-value contract, so this file
* pins the integration: a realistic event sequence has to leave the
* canvas in the expected resolved state (or surface the error to the
* alert store on RUN_ERROR).
*/
import { BuildStatus } from "@/constants/enums";
import { runFlowAGUI } from "@/controllers/API/agui/run-flow-bridge";
import useAlertStore from "@/stores/alertStore";
import useFlowStore from "@/stores/flowStore";
import { useMessagesStore } from "@/stores/messagesStore";
beforeAll(() => {
// jsdom lacks the codecs the AG-UI SSE decoder uses; pull them from
// node's `util` exactly as `run-agent.test.ts` does.
const g = global as {
fetch?: unknown;
TextEncoder?: unknown;
TextDecoder?: unknown;
};
const util = require("util") as {
TextEncoder: typeof TextEncoder;
TextDecoder: typeof TextDecoder;
};
if (typeof g.TextEncoder !== "function") g.TextEncoder = util.TextEncoder;
if (typeof g.TextDecoder !== "function") g.TextDecoder = util.TextDecoder;
if (typeof g.fetch !== "function") {
g.fetch = () => Promise.reject(new Error("fetch not stubbed"));
}
});
beforeEach(() => {
// Reset only the slices the bridge writes to. Touching the full state
// would clobber action implementations (Zustand stores actions in the
// same state object).
useFlowStore.setState({
flowBuildStatus: {},
flowPool: {},
isBuilding: true,
buildInfo: null,
});
useAlertStore.setState({
errorData: { title: "", list: [] },
notificationList: [],
tempNotificationList: [],
});
// The CUSTOM(langflow.event, add_message) branch routes through
// ``handleMessageEvent``, which appends to ``useMessagesStore``. Clearing
// here keeps the assertion below honest across reruns.
useMessagesStore.setState({ messages: [] });
});
// Minimal fake Response sufficient for `runHttpRequest`'s SSE decoder.
// Lifted from the pattern in `run-agent.test.ts` so the bridge sees
// exactly what the wire produces.
function sseResponse(events: object[]): unknown {
const encoder = new (
globalThis as { TextEncoder: typeof TextEncoder }
).TextEncoder();
const payload = encoder.encode(
events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join(""),
);
let delivered = false;
return {
ok: true,
status: 200,
headers: {
get(key: string) {
return key.toLowerCase() === "content-type"
? "text/event-stream"
: null;
},
},
body: {
getReader() {
return {
async read() {
if (delivered) return { done: true, value: undefined };
delivered = true;
return { done: false, value: payload };
},
async cancel() {
/* no-op */
},
};
},
},
text: async () => "",
};
}
describe("runFlowAGUI end-to-end", () => {
it("folds a full RUN_STARTED → STATE_DELTA → RUN_FINISHED stream into flowStore", async () => {
const events = [
{
type: "RUN_STARTED",
threadId: "thread-1",
runId: "run-1",
timestamp: 0,
},
{
type: "STATE_SNAPSHOT",
snapshot: { nodes: {} },
timestamp: 0,
},
{
type: "STATE_DELTA",
delta: [
{
op: "add",
path: "/nodes/node-a",
value: { status: "running", output: null },
},
],
timestamp: 0,
},
{
type: "STATE_DELTA",
delta: [
{
op: "replace",
path: "/nodes/node-a",
value: {
status: "success",
output: { results: { text: "hello" } },
},
},
],
timestamp: 0,
},
{
type: "CUSTOM",
name: "langflow.event",
value: {
event_type: "add_message",
data: {
id: "msg-1",
text: "hello from agent",
sender: "AI",
sender_name: "agent",
session_id: "thread-1",
},
},
timestamp: 0,
rawEvent: {},
},
{
type: "RUN_FINISHED",
threadId: "thread-1",
runId: "run-1",
timestamp: 0,
},
];
const fetchSpy = jest
.spyOn(global as { fetch: typeof fetch }, "fetch")
.mockImplementation((async () =>
sseResponse(events)) as unknown as typeof fetch);
try {
await runFlowAGUI({
flowId: "flow-1",
message: "hi",
threadId: "thread-1",
});
const flow = useFlowStore.getState();
expect(flow.isBuilding).toBe(false);
expect(flow.buildInfo).toEqual({ success: true });
// Final delta carries a real output, so the flow pool gets the entry
// stamped with the runId server announced via RUN_STARTED.
expect(flow.flowPool["node-a"]).toHaveLength(1);
expect(flow.flowPool["node-a"][0]).toMatchObject({
id: "node-a",
run_id: "run-1",
valid: true,
});
// `revertBuiltStatusFromBuilding` is called after the run, but the
// final status here came in as `success` already so it stays BUILT.
expect(flow.flowBuildStatus["node-a"]?.status).toBe(BuildStatus.BUILT);
// The CUSTOM(langflow.event) frame was routed through
// ``handleMessageEvent``, which appended to ``useMessagesStore``.
// Pinning the side effect proves the bridge dispatch actually wires
// a realistic ``event_type`` through to the chat-view handler, not
// just an inert pass-through.
const messages = useMessagesStore.getState().messages;
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({ id: "msg-1", sender: "AI" });
} finally {
fetchSpy.mockRestore();
}
});
it("surfaces RUN_ERROR to alertStore and clears isBuilding", async () => {
const events = [
{
type: "RUN_STARTED",
threadId: "thread-1",
runId: "run-1",
timestamp: 0,
},
{
type: "RUN_ERROR",
message: "graph blew up",
timestamp: 0,
},
];
const fetchSpy = jest
.spyOn(global as { fetch: typeof fetch }, "fetch")
.mockImplementation((async () =>
sseResponse(events)) as unknown as typeof fetch);
try {
await runFlowAGUI({ flowId: "flow-1", message: "boom" });
const flow = useFlowStore.getState();
const alert = useAlertStore.getState();
expect(flow.isBuilding).toBe(false);
expect(flow.buildInfo).toEqual({
error: ["graph blew up"],
success: false,
});
expect(alert.errorData.title).toBe("Workflow run failed");
expect(alert.errorData.list).toEqual(["graph blew up"]);
} finally {
fetchSpy.mockRestore();
}
});
it("records a failure when the stream closes without RUN_FINISHED or RUN_ERROR", async () => {
/**
* Server-side crash, truncated proxy response, or a buggy stream can
* close the SSE without delivering a terminal event. ``buildInfo`` must
* surface that as an error so the caller's analytics doesn't record a
* silent close as success.
*/
const events = [
{
type: "RUN_STARTED",
threadId: "thread-1",
runId: "run-1",
timestamp: 0,
},
// No RUN_FINISHED or RUN_ERROR.
];
const fetchSpy = jest
.spyOn(global as { fetch: typeof fetch }, "fetch")
.mockImplementation((async () =>
sseResponse(events)) as unknown as typeof fetch);
try {
await runFlowAGUI({ flowId: "flow-1", message: "silent" });
const flow = useFlowStore.getState();
expect(flow.isBuilding).toBe(false);
expect(flow.buildInfo).toEqual({
error: ["Workflow run ended unexpectedly"],
success: false,
});
} finally {
fetchSpy.mockRestore();
}
});
it("aborts the in-flight SSE when the caller's AbortSignal fires", async () => {
/**
* ``flowStore.stopBuilding`` aborts its build controller. The signal is
* passed to ``runFlowAGUI`` so the underlying fetch is cancelled too;
* without the plumbing, Stop would update local state but the SSE
* stream would keep running on the wire.
*/
const signals: AbortSignal[] = [];
const fetchSpy = jest
.spyOn(global as { fetch: typeof fetch }, "fetch")
.mockImplementation(((_input: RequestInfo | URL, init?: RequestInit) => {
if (init?.signal) signals.push(init.signal);
return Promise.resolve({
ok: true,
status: 200,
headers: {
get(key: string) {
return key.toLowerCase() === "content-type"
? "text/event-stream"
: null;
},
},
body: {
getReader() {
return {
read: () => new Promise(() => {}),
cancel: async () => {},
};
},
},
text: async () => "",
} as unknown as Response);
}) as unknown as typeof fetch);
try {
const controller = new AbortController();
const runPromise = runFlowAGUI({
flowId: "flow-1",
message: "stop-me",
signal: controller.signal,
});
// Yield so fetch + subscribe wire up.
await Promise.resolve();
await Promise.resolve();
expect(signals.length).toBe(1);
expect(signals[0].aborted).toBe(false);
controller.abort();
await runPromise;
expect(signals[0].aborted).toBe(true);
const flow = useFlowStore.getState();
expect(flow.isBuilding).toBe(false);
// ``buildInfo`` is recorded as a failure (so trackFlowBuild sees the
// cancellation as an error) but without an ``error`` string: the
// caller's stopBuilding already shows the user-facing "Build stopped"
// alert and a duplicate inside the canvas footer would trip locators.
expect(flow.buildInfo).toEqual({ success: false });
} finally {
fetchSpy.mockRestore();
}
});
});

View File

@ -0,0 +1,141 @@
/**
* Tests for the AG-UI bridge event dispatcher.
*
* The handler is extracted so the terminal-event contract is unit-testable
* without standing up a real flowStore or a fake long-lived SSE stream:
* RUN_FINISHED and RUN_ERROR must return ``true`` so the subscribe wrapper
* tears the subscription down. STATE_DELTA, RUN_STARTED, and CUSTOM must
* return ``false`` so the run keeps streaming.
*/
import { type BaseEvent, EventType } from "@ag-ui/client";
import {
type BridgeContext,
handleAGUIEvent,
} from "@/controllers/API/agui/run-flow-bridge";
function makeRecordingContext() {
const calls: string[] = [];
const ctx: BridgeContext = {
setRunId: (runId) => calls.push(`setRunId:${runId}`),
applyDelta: (ops) => calls.push(`applyDelta:${ops.length}`),
handleCustomEvent: (eventType) => calls.push(`custom:${eventType}`),
onFinished: () => calls.push("finished"),
onError: (message) => calls.push(`error:${message}`),
};
return { ctx, calls };
}
describe("handleAGUIEvent terminal contract", () => {
it("returns true for RUN_FINISHED and invokes onFinished", () => {
const { ctx, calls } = makeRecordingContext();
const terminal = handleAGUIEvent(
{ type: EventType.RUN_FINISHED } as BaseEvent,
ctx,
);
expect(terminal).toBe(true);
expect(calls).toEqual(["finished"]);
});
it("returns true for RUN_ERROR and surfaces the error message", () => {
const { ctx, calls } = makeRecordingContext();
const terminal = handleAGUIEvent(
{ type: EventType.RUN_ERROR, message: "boom" } as unknown as BaseEvent,
ctx,
);
expect(terminal).toBe(true);
expect(calls).toEqual(["error:boom"]);
});
it("returns true for RUN_ERROR with no message, falling back to a default", () => {
const { ctx, calls } = makeRecordingContext();
const terminal = handleAGUIEvent(
{ type: EventType.RUN_ERROR } as BaseEvent,
ctx,
);
expect(terminal).toBe(true);
expect(calls).toEqual(["error:Unknown run error"]);
});
});
describe("handleAGUIEvent non-terminal contract", () => {
it("returns false for RUN_STARTED and propagates the runId", () => {
const { ctx, calls } = makeRecordingContext();
const terminal = handleAGUIEvent(
{ type: EventType.RUN_STARTED, runId: "r1" } as unknown as BaseEvent,
ctx,
);
expect(terminal).toBe(false);
expect(calls).toEqual(["setRunId:r1"]);
});
it("returns false for RUN_STARTED with no runId, leaving setRunId untouched", () => {
const { ctx, calls } = makeRecordingContext();
const terminal = handleAGUIEvent(
{ type: EventType.RUN_STARTED } as BaseEvent,
ctx,
);
expect(terminal).toBe(false);
expect(calls).toEqual([]);
});
it("returns false for STATE_DELTA and forwards the patch ops", () => {
const { ctx, calls } = makeRecordingContext();
const terminal = handleAGUIEvent(
{
type: EventType.STATE_DELTA,
delta: [
{ op: "add", path: "/nodes/a", value: {} },
{ op: "add", path: "/nodes/b", value: {} },
],
} as unknown as BaseEvent,
ctx,
);
expect(terminal).toBe(false);
expect(calls).toEqual(["applyDelta:2"]);
});
it("returns false for CUSTOM(langflow.event) and forwards the event type", () => {
const { ctx, calls } = makeRecordingContext();
const terminal = handleAGUIEvent(
{
type: EventType.CUSTOM,
name: "langflow.event",
value: { event_type: "add_message", data: { id: "m1" } },
} as unknown as BaseEvent,
ctx,
);
expect(terminal).toBe(false);
expect(calls).toEqual(["custom:add_message"]);
});
it("returns false for CUSTOM with a foreign name and skips the handler", () => {
const { ctx, calls } = makeRecordingContext();
const terminal = handleAGUIEvent(
{
type: EventType.CUSTOM,
name: "some.other",
value: { event_type: "add_message", data: {} },
} as unknown as BaseEvent,
ctx,
);
expect(terminal).toBe(false);
expect(calls).toEqual([]);
});
});

View File

@ -0,0 +1,191 @@
/**
* v2 workflows run service.
*
* Builds the native `WorkflowRunRequest` body that
* `POST /api/v2/workflows` accepts and runs it through a thin `HttpAgent`
* subclass so the frontend keeps consuming the typed AG-UI event stream
* (`stream_protocol: "agui"`) emitted by the server.
*
* The endpoint Pydantic model has `extra="forbid"`, so this builder must
* never leak AG-UI `RunAgentInput` keys (`threadId`, `runId`,
* `forwardedProps`, `state`, `messages`, `tools`, `context`) onto the wire.
*/
import {
HttpAgent,
type HttpAgentConfig,
type RunAgentInput,
} from "@ag-ui/client";
/** Execution mode accepted by the v2 workflows endpoint. */
export type WorkflowMode = "sync" | "stream" | "background";
/** Streaming protocol selector (server-side adapter registry). */
export type StreamProtocol = "agui" | "langflow";
/** Options describing one Langflow workflow run. */
export interface WorkflowRunOptions {
/** The Langflow flow id to run. Required by the v2 endpoint. */
flowId: string;
/** User chat input (last user message) — maps to `input_value`. */
message?: string;
/** Component-keyed parameter overrides, e.g. `{ChatInput-abc: {input_value: "..."}}`. */
tweaks?: Record<string, Record<string, unknown>>;
/** Execution mode; defaults to `stream`. */
mode?: WorkflowMode;
/** Chat-session id — maps to the endpoint's `session_id`. Server derives `run_id`/`thread_id`. */
threadId?: string;
/** Optional partial-run start vertex id. */
startComponentId?: string;
/** Optional partial-run stop vertex id. */
stopComponentId?: string;
/** Current flow data (nodes + edges) to run; falls back to the DB copy if omitted. */
flowData?: { nodes: unknown[]; edges: unknown[] };
/** Runtime file references the graph build needs (e.g. uploaded file paths). */
files?: string[];
/**
* When true, route the request to the public-flow endpoint
* (``/api/v2/workflows/public``) instead of the authenticated one.
*
* Set by the shareable-playground popup so the backend can apply the
* public-access mitigations (per-visitor virtual_flow_id, session
* namespacing, file-path validation, owner impersonation) that mirror
* v1's ``build_public_tmp``.
*
* When enabled the request body is narrowed too: ``tweaks`` and
* ``flowData`` are silently dropped because the public schema forbids
* them (visitors must never override the stored flow definition).
*/
usePublicEndpoint?: boolean;
}
/** The v2 workflows endpoint path. */
export const WORKFLOWS_ENDPOINT = "/api/v2/workflows";
/** The v2 public workflows endpoint path (shareable playground). */
export const WORKFLOWS_PUBLIC_ENDPOINT = "/api/v2/workflows/public";
/**
* Wire shape for `POST /api/v2/workflows`. Mirrors
* `lfx.schema.workflow.WorkflowRunRequest`; the Pydantic model rejects
* extra fields, so this type intentionally has no escape hatch.
*/
export interface WorkflowRunRequestBody {
flow_id: string;
input_value: string;
mode: WorkflowMode;
stream_protocol: StreamProtocol;
tweaks?: Record<string, Record<string, unknown>>;
session_id?: string;
data?: { nodes: unknown[]; edges: unknown[] };
files?: string[];
start_component_id?: string;
stop_component_id?: string;
}
/**
* Build a native `WorkflowRunRequest` body for the v2 workflows endpoint.
*
* The frontend pins `stream_protocol: "agui"` so the SSE response is a
* typed AG-UI event stream the existing consumers already understand.
* `run_id` and `thread_id` are derived server-side from `session_id` and
* `flow_id`; we only forward `session_id` when the caller actually has one.
*/
export function buildWorkflowRunRequest(
opts: WorkflowRunOptions,
): WorkflowRunRequestBody {
const mode = opts.mode ?? "stream";
// ``createWorkflowAgent`` always patches ``Accept: text/event-stream`` and
// ``HttpAgent.run`` decodes the response as SSE. ``mode=sync`` returns
// ``application/json`` and ``mode=background`` returns a job JSON, so
// either of those modes through this builder would mis-decode at runtime.
// The only in-tree caller (``runFlowAGUI``) sends ``stream``; this guard
// catches accidental misuse early instead of silently producing a broken
// run. Sync / background callers should go through a JSON fetch path.
if (mode !== "stream") {
throw new Error(
`createWorkflowAgent only supports mode="stream"; got "${mode}". ` +
`Use a JSON fetch against ${WORKFLOWS_ENDPOINT} for sync / background.`,
);
}
const body: WorkflowRunRequestBody = {
flow_id: opts.flowId,
input_value: opts.message ?? "",
mode,
stream_protocol: "agui",
};
if (opts.threadId) body.session_id = opts.threadId;
// The public endpoint's schema (extra="forbid") rejects tweaks/data:
// visitors must never override the stored flow definition. Drop them
// here instead of letting the request fail with a 422.
const isPublic = !!opts.usePublicEndpoint;
if (!isPublic && opts.tweaks) body.tweaks = opts.tweaks;
if (opts.startComponentId) body.start_component_id = opts.startComponentId;
if (opts.stopComponentId) body.stop_component_id = opts.stopComponentId;
if (!isPublic && opts.flowData) body.data = opts.flowData;
if (opts.files && opts.files.length > 0) body.files = opts.files;
return body;
}
/** Construction options for the workflow agent. */
export interface WorkflowAgentOptions {
/** Native body to POST. Built via `buildWorkflowRunRequest`. */
body: WorkflowRunRequestBody;
/** Override the endpoint URL (defaults to `/api/v2/workflows`). */
url?: string;
/** Extra headers; cookies and `fetch-intercept`'d headers are sent automatically. */
headers?: Record<string, string>;
}
/**
* `HttpAgent` instance with `requestInit` swapped to POST a native
* `WorkflowRunRequest` body instead of the AG-UI `RunAgentInput`.
*
* `HttpAgent` exposes `requestInit` as the documented override surface
* ("Override this to customize the request"). We patch it on the instance
* rather than subclassing because the project's ts-jest targets `es5`,
* which can't extend the CJS-compiled class. Patching keeps us in sync
* with future AG-UI updates (SSE framing, abort, headers) instead of
* forking the whole client.
*/
export type WorkflowHttpAgent = HttpAgent & {
workflowBody: WorkflowRunRequestBody;
};
/**
* Create a workflow agent preconfigured for the v2 workflows endpoint.
*
* Auth is carried by the browser: same-origin cookies are sent automatically,
* and Langflow's `fetch-intercept` registration adds any custom headers.
*/
export function createWorkflowAgent(
opts: WorkflowAgentOptions,
): WorkflowHttpAgent {
const config: HttpAgentConfig = {
url: opts.url ?? WORKFLOWS_ENDPOINT,
headers: opts.headers,
};
const agent = new HttpAgent(config) as WorkflowHttpAgent;
agent.workflowBody = opts.body;
// `requestInit` is the documented override surface on `HttpAgent`.
// Swap it on the instance so the wire body is the native
// `WorkflowRunRequest`; the AG-UI `RunAgentInput` passed to `run()`
// is used only for client-side correlation.
(
agent as unknown as {
requestInit: (input: RunAgentInput) => RequestInit;
}
).requestInit = function requestInit(_input: RunAgentInput): RequestInit {
return {
method: "POST",
headers: {
...agent.headers,
"Content-Type": "application/json",
Accept: "text/event-stream",
},
body: JSON.stringify(agent.workflowBody),
signal: agent.abortController.signal,
};
};
return agent;
}

View File

@ -0,0 +1,323 @@
/**
* Bridge: run a workflow through the AG-UI service and update flowStore.
*
* ``flowStore.buildFlow`` calls this function to drive a run through the
* v2 workflows endpoint. It starts the AG-UI HttpAgent and folds each
* event into the existing flow-store methods (``updateBuildStatus``,
* ``addDataToFlowPool``, ``setBuildInfo``, ``setIsBuilding``) so the
* canvas and "built successfully" toast keep working.
*/
import { type BaseEvent, EventType } from "@ag-ui/client";
import { handleMessageEvent } from "@/components/core/playgroundComponent/chat-view/utils/message-event-handler";
import { BuildStatus } from "@/constants/enums";
import useAlertStore from "@/stores/alertStore";
import useFlowStore from "@/stores/flowStore";
import type {
ChatInputType,
ChatOutputType,
VertexBuildTypeAPI,
VertexDataTypeAPI,
} from "@/types/api";
import {
buildWorkflowRunRequest,
createWorkflowAgent,
WORKFLOWS_PUBLIC_ENDPOINT,
type WorkflowRunOptions,
} from "./run-agent";
const AGUI_STATUS_TO_BUILD_STATUS: Record<string, BuildStatus> = {
pending: BuildStatus.TO_BUILD,
running: BuildStatus.BUILDING,
success: BuildStatus.BUILT,
error: BuildStatus.ERROR,
};
interface JsonPatchOp {
op: string;
path: string;
value?: unknown;
}
interface AGUINodeState {
status: string;
output: VertexDataTypeAPI | null;
}
/**
* Hooks the bridge exposes to {@link handleAGUIEvent}. Injecting them keeps
* the event-dispatch logic testable without importing the real flow / alert
* stores or the chat-view message handler.
*/
export interface BridgeContext {
setRunId: (runId: string) => void;
applyDelta: (ops: JsonPatchOp[]) => void;
handleCustomEvent: (eventType: string, data: unknown) => void;
onFinished: () => void;
onError: (message: string) => void;
}
/**
* Dispatch a single AG-UI event into the bridge context.
*
* Returns ``true`` when the event is terminal (``RUN_FINISHED`` or
* ``RUN_ERROR``); the caller must then tear down the subscription and
* resolve the run. Pulling this out of the inline subscribe handler keeps
* the terminal-event contract unit-testable.
*/
export function handleAGUIEvent(event: BaseEvent, ctx: BridgeContext): boolean {
if (event.type === EventType.RUN_STARTED) {
const started = event as unknown as { runId?: string };
if (started.runId) ctx.setRunId(started.runId);
return false;
}
if (event.type === EventType.STATE_DELTA) {
const ops = (event as unknown as { delta?: JsonPatchOp[] }).delta ?? [];
ctx.applyDelta(ops);
return false;
}
if (event.type === EventType.CUSTOM) {
const custom = event as unknown as {
name?: string;
value?: { event_type?: string; data?: unknown };
};
if (custom.name === "langflow.event" && custom.value?.event_type) {
ctx.handleCustomEvent(custom.value.event_type, custom.value.data);
}
return false;
}
if (event.type === EventType.RUN_FINISHED) {
ctx.onFinished();
return true;
}
if (event.type === EventType.RUN_ERROR) {
const message =
(event as unknown as { message?: string }).message ?? "Unknown run error";
ctx.onError(message);
return true;
}
return false;
}
/**
* @internal
*
* Exported only so the JSON-Patch parsing + status fallback can be pinned
* directly. `runFlowAGUI` is the sole production caller; tests live in
* `__tests__/apply-state-delta.test.ts`. The JSDoc `@internal` tag is what
* TypeScript's `--stripInternal` recognizes, so this stays out of generated
* `.d.ts` if the project ever turns that flag on.
*/
export function applyStateDelta(
ops: JsonPatchOp[],
runId: string,
nodeIds: Set<string>,
): void {
const flowStore = useFlowStore.getState();
for (const op of ops) {
const match = /^\/nodes\/([^/]+)$/.exec(op.path);
if (!match) continue;
if (op.op !== "add" && op.op !== "replace") continue;
const nodeId = match[1];
const value = op.value as AGUINodeState | undefined;
if (!value || typeof value !== "object") continue;
const buildStatus =
AGUI_STATUS_TO_BUILD_STATUS[value.status] ?? BuildStatus.BUILDING;
flowStore.updateBuildStatus([nodeId], buildStatus);
nodeIds.add(nodeId);
// Drive the canvas edge animation in step with build status so the AG-UI
// path matches the v1 build callbacks: edges from a node light up while
// it runs and stop when it finishes (either way). Without this only the
// bridge's final ``finish()`` clears edges, and they never animate during
// the run.
if (value.status === "running") {
flowStore.updateEdgesRunningByNodes([nodeId], true);
} else if (value.status === "success" || value.status === "error") {
flowStore.updateEdgesRunningByNodes([nodeId], false);
}
// Only the final per-vertex emission carries the result data; running
// states have ``output: null`` and contribute no flow-pool entry.
if (value.output) {
const entry: VertexBuildTypeAPI = {
id: nodeId,
inactivated_vertices: null,
next_vertices_ids: [],
top_level_vertices: [],
run_id: runId,
valid: value.status === "success",
data: value.output,
timestamp: new Date().toISOString(),
params: null,
messages: [] as ChatOutputType[] | ChatInputType[],
artifacts: null,
};
flowStore.addDataToFlowPool(entry, nodeId);
}
}
}
/**
* Run a workflow through the AG-UI service and update flowStore as events
* arrive. Resolves when the run ends (RUN_FINISHED or RUN_ERROR), the
* underlying observable errors, or ``signal`` aborts.
*
* Passing the caller's ``AbortSignal`` (typically
* ``flowStore.buildController.signal``) lets ``flowStore.stopBuilding`` cancel
* the in-flight SSE request, not just the local build state. Without it the
* agent's internal AbortController would keep streaming after Stop.
*/
export async function runFlowAGUI(
opts: WorkflowRunOptions & { signal?: AbortSignal },
): Promise<void> {
// The shareable-playground popup posts on behalf of a visitor that
// does not own the flow. Route those runs to the public endpoint so
// the backend applies the public-access mitigations (virtual_flow_id,
// session namespacing, file-path validation, owner impersonation).
// The canvas's regular runs keep going to ``/api/v2/workflows``.
const usePublicEndpoint =
opts.usePublicEndpoint ?? useFlowStore.getState().playgroundPage;
const body = buildWorkflowRunRequest({ ...opts, usePublicEndpoint });
const agent = createWorkflowAgent({
body,
url: usePublicEndpoint ? WORKFLOWS_PUBLIC_ENDPOINT : undefined,
});
// Replace the agent's internal AbortController with one tied to the
// caller's signal so an upstream stop also aborts the SSE fetch. Without
// this, the agent owns its own controller and the stream keeps running
// even after ``stopBuilding`` aborts the caller's controller.
if (opts.signal) {
const linkedController = new AbortController();
if (opts.signal.aborted) {
linkedController.abort();
} else {
opts.signal.addEventListener("abort", () => linkedController.abort(), {
once: true,
});
}
(agent as { abortController: AbortController }).abortController =
linkedController;
}
const flowStore = useFlowStore.getState();
const setErrorData = useAlertStore.getState().setErrorData;
const touchedNodeIds = new Set<string>();
// Server derives run_id from session_id/flow_id and announces it via
// RUN_STARTED. Until that arrives we have no id to stamp on flow-pool
// entries, so we fall back to an empty string (matches the legacy
// contract for an unresolved run).
let runId = "";
// ``buildInfo`` must be set on every terminal path (RUN_FINISHED,
// RUN_ERROR, observable error, complete-without-terminal-event, abort)
// so the caller's analytics ``trackFlowBuild`` can read success vs
// failure. Without this, a silent ``complete:`` would leave ``buildInfo``
// ``null`` and ``trackFlowBuild`` would mis-record the run as success.
let terminalEventSeen = false;
// `agent.run` still needs a `RunAgentInput` for the client-side apply
// pipeline (subscriber correlation); the actual wire body is the native
// `WorkflowRunRequest` set on the agent. These ids stay local.
const runInput = {
threadId: opts.threadId ?? "",
runId: "",
state: {},
messages: [],
tools: [],
context: [],
forwardedProps: {},
};
// Side-channel: the backend mirrors message-shaped events (add_message,
// token, remove_message, error) as a `langflow.event` CustomEvent so
// the playground's chat-view utilities can consume them in their v1
// shape. A follow-up can retire this once chat-view consumes the
// AG-UI `TEXT_MESSAGE_*` lifecycle directly.
const ctx: BridgeContext = {
setRunId: (r) => {
runId = r;
},
applyDelta: (ops) => applyStateDelta(ops, runId, touchedNodeIds),
handleCustomEvent: (eventType, data) => handleMessageEvent(eventType, data),
onFinished: () => {
terminalEventSeen = true;
flowStore.setBuildInfo({ success: true });
},
onError: (message) => {
terminalEventSeen = true;
flowStore.setBuildInfo({ error: [message], success: false });
setErrorData({ title: "Workflow run failed", list: [message] });
},
};
return new Promise<void>((resolve) => {
let settled = false;
const finish = () => {
if (settled) return;
settled = true;
flowStore.updateEdgesRunningByNodes([...touchedNodeIds], false);
flowStore.setIsBuilding(false);
flowStore.revertBuiltStatusFromBuilding();
resolve();
};
const abortHandler = () => {
// ``buildController.abort`` was called from ``flowStore.stopBuilding``.
// Mark the run as a failure so analytics records the cancellation and
// tear the subscription down (the agent's internal AbortController is
// already linked to this signal, so the SSE fetch is already aborting).
// Do NOT write an ``error`` string here: ``stopBuilding`` already
// surfaces the user-facing "Build stopped" alert. A second message in
// ``buildInfo.error`` would render the same text inside the canvas
// footer and trip strict locators ("build stopped" resolved twice).
if (!terminalEventSeen) {
flowStore.setBuildInfo({ success: false });
}
subscription.unsubscribe();
finish();
};
if (opts.signal) {
if (opts.signal.aborted) {
// Already aborted before we even started. Skip the subscribe.
flowStore.setBuildInfo({ success: false });
finish();
return;
}
opts.signal.addEventListener("abort", abortHandler, { once: true });
}
const subscription = agent.run(runInput).subscribe({
next: (event: BaseEvent) => {
if (handleAGUIEvent(event, ctx)) {
// Tear the subscription down on the terminal event instead of
// waiting for `complete:` from the SSE stream. A server-side
// keepalive or buffered chunk after the terminal event can leave
// the observable open, which would leave the canvas stuck on
// ``isBuilding=true`` indefinitely.
subscription.unsubscribe();
finish();
}
},
error: (err: Error) => {
flowStore.setBuildInfo({ error: [err.message], success: false });
setErrorData({ title: "Workflow run failed", list: [err.message] });
subscription.unsubscribe();
finish();
},
complete: () => {
// ``complete`` without a terminal event means the SSE stream closed
// cleanly but never delivered RUN_FINISHED/RUN_ERROR (server crash,
// truncated response, proxy timeout). Record an error so analytics
// doesn't treat a silent close as success.
if (!terminalEventSeen) {
flowStore.setBuildInfo({
error: ["Workflow run ended unexpectedly"],
success: false,
});
}
subscription.unsubscribe();
finish();
},
});
});
}

View File

@ -50,15 +50,24 @@ jest.mock("../darkStore", () => ({
},
}));
jest.mock("../flowsManagerStore", () => ({
__esModule: true,
default: {
getState: () => ({
setCurrentFlow: jest.fn(),
takeSnapshot: jest.fn(),
}),
},
}));
jest.mock("../flowsManagerStore", () => {
const state: { currentFlow: { id: string; name: string } | undefined } = {
currentFlow: undefined,
};
return {
__esModule: true,
default: {
getState: () => ({
...state,
setCurrentFlow: jest.fn(),
takeSnapshot: jest.fn(),
}),
__setCurrentFlow: (flow: { id: string; name: string } | undefined) => {
state.currentFlow = flow;
},
},
};
});
jest.mock("../globalVariablesStore/globalVariables", () => ({
useGlobalVariablesStore: {
@ -90,10 +99,30 @@ jest.mock("@/utils/utils", () => ({
brokenEdgeMessage: jest.fn(),
}));
// runFlowAGUI is exercised end-to-end in its own bridge tests; here we just
// need a controllable replacement so the buildFlow integration can be tested
// without touching the network.
jest.mock("@/controllers/API/agui/run-flow-bridge", () => ({
runFlowAGUI: jest.fn(),
}));
// Keep reactflowUtils' real behaviour for the rest of the suite; only flip
// validateNodes / validateEdge to no-ops so the buildFlow analytics tests can
// reach the runFlowAGUI call with an empty (test-fixture) graph.
jest.mock("../../utils/reactflowUtils", () => {
const actual = jest.requireActual("../../utils/reactflowUtils");
return {
...actual,
validateNodes: jest.fn(() => []),
validateEdge: jest.fn(() => []),
};
});
// Note: Some utility modules may not exist in test environment
// The store should handle missing utilities gracefully
import { checkCodeValidity } from "@/CustomNodes/helpers/check-code-validity";
import type { LogsLogType, VertexBuildTypeAPI } from "@/types/api";
import type { AllNodeType, EdgeType } from "@/types/flow";
import useFlowStore, {
completeNodeUpdate,
@ -867,9 +896,8 @@ describe("useFlowStore", () => {
const createEdge = (
id: string,
sourceHandleId: string,
// biome-ignore lint/suspicious/noExplicitAny: legacy
overrides: Partial<any> = {},
) =>
overrides: Partial<EdgeType> = {},
): EdgeType =>
({
id,
source: `src-${id}`,
@ -878,8 +906,7 @@ describe("useFlowStore", () => {
className: "",
data: { sourceHandle: { id: sourceHandleId } },
...overrides,
// biome-ignore lint/suspicious/noExplicitAny: legacy
}) as any;
}) as unknown as EdgeType;
it("should clear all edge animations when no nextIds provided", () => {
const { result } = renderHook(() => useFlowStore());
@ -988,15 +1015,13 @@ describe("useFlowStore", () => {
id: "node-1",
data: { results: {} },
valid: true,
// biome-ignore lint/suspicious/noExplicitAny: legacy
} as any;
} as unknown as VertexBuildTypeAPI;
const mockVertexData2 = {
id: "node-1",
data: { results: { other: true } },
valid: true,
// biome-ignore lint/suspicious/noExplicitAny: legacy
} as any;
} as unknown as VertexBuildTypeAPI;
it("should add data to new nodeId entry", () => {
const { result } = renderHook(() => useFlowStore());
@ -1043,14 +1068,16 @@ describe("useFlowStore", () => {
});
describe("appendLogToFlowPool", () => {
// biome-ignore lint/suspicious/noExplicitAny: legacy
const mockLog = { name: "Test Log", message: "hello", type: "info" } as any;
const mockLog = {
name: "Test Log",
message: "hello",
type: "info",
} as unknown as LogsLogType;
const mockLog2 = {
name: "Second Log",
message: "world",
type: "info",
// biome-ignore lint/suspicious/noExplicitAny: legacy
} as any;
} as unknown as LogsLogType;
it("creates a new pool entry with the log when no entry exists for nodeId", () => {
const { result } = renderHook(() => useFlowStore());
@ -1108,4 +1135,82 @@ describe("useFlowStore", () => {
expect(latest["output_b"]).toEqual([mockLog2]);
});
});
describe("buildFlow analytics — trackFlowBuild integration", () => {
let mockedRunFlow: jest.Mock;
let trackFlowBuildMock: jest.Mock;
beforeAll(() => {
// Cast through unknown so the test does not depend on the bridge module's
// public type — we only care about controlling the side effect.
const bridge = jest.requireMock(
"@/controllers/API/agui/run-flow-bridge",
) as { runFlowAGUI: jest.Mock };
mockedRunFlow = bridge.runFlowAGUI;
const analytics = jest.requireMock("@/customization/utils/analytics") as {
trackFlowBuild: jest.Mock;
};
trackFlowBuildMock = analytics.trackFlowBuild;
// Make `currentFlow` resolvable inside buildFlow.
const flowsManager = jest.requireMock("../flowsManagerStore") as {
default: {
__setCurrentFlow: (
flow: { id: string; name: string } | undefined,
) => void;
};
};
flowsManager.default.__setCurrentFlow({
id: "flow-abc",
name: "Test Flow",
});
});
beforeEach(() => {
mockedRunFlow.mockReset();
trackFlowBuildMock.mockReset();
act(() => {
useFlowStore.setState({
nodes: [],
edges: [],
buildInfo: null,
flowBuildStatus: {},
isBuilding: false,
componentsToUpdate: [],
});
});
});
it("fires trackFlowBuild with isError=false after a successful run", async () => {
mockedRunFlow.mockImplementation(async () => {
// The bridge writes success into the store on `RUN_FINISHED`.
useFlowStore.setState({ buildInfo: { success: true } });
});
await useFlowStore.getState().buildFlow({});
expect(mockedRunFlow).toHaveBeenCalledTimes(1);
expect(trackFlowBuildMock).toHaveBeenCalledWith("Test Flow", false, {
flowId: "flow-abc",
});
});
it("fires trackFlowBuild with isError=true and the error list after a failure", async () => {
const errorList = ["boom"];
mockedRunFlow.mockImplementation(async () => {
useFlowStore.setState({
buildInfo: { success: false, error: errorList },
});
});
await useFlowStore.getState().buildFlow({});
expect(mockedRunFlow).toHaveBeenCalledTimes(1);
expect(trackFlowBuildMock).toHaveBeenCalledWith("Test Flow", true, {
flowId: "flow-abc",
error: errorList,
});
});
});
});

View File

@ -6,19 +6,13 @@ import {
type Node,
type NodeChange,
} from "@xyflow/react";
import { cloneDeep, zip } from "lodash";
import { cloneDeep } from "lodash";
import { create } from "zustand";
import { checkCodeValidity } from "@/CustomNodes/helpers/check-code-validity";
import { queryClient } from "@/contexts";
import {
ENABLE_DATASTAX_LANGFLOW,
ENABLE_INSPECTION_PANEL,
} from "@/customization/feature-flags";
import {
track,
trackDataLoaded,
trackFlowBuild,
} from "@/customization/utils/analytics";
import { runFlowAGUI } from "@/controllers/API/agui/run-flow-bridge";
import { ENABLE_INSPECTION_PANEL } from "@/customization/feature-flags";
import { track, trackFlowBuild } from "@/customization/utils/analytics";
import { brokenEdgeMessage } from "@/utils/utils";
import { BuildStatus, EventDeliveryType } from "../constants/enums";
import i18n from "../i18n";
@ -36,7 +30,6 @@ import type {
FlowStoreType,
VertexLayerElementType,
} from "../types/zustand/flow";
import { buildFlowVerticesWithFallback } from "../utils/buildUtils";
import {
buildPositionDictionary,
checkChatInput,
@ -190,8 +183,7 @@ const useFlowStore = create<FlowStoreType>((set, get) => ({
set({ isBuilding: false });
get().revertBuiltStatusFromBuilding();
useAlertStore.getState().setErrorData({
// biome-ignore lint/suspicious/noExplicitAny: legacy
title: (i18n as any).t("alerts.buildStopped"),
title: i18n.t("alerts.buildStopped"),
});
},
isPending: true,
@ -819,7 +811,6 @@ const useFlowStore = create<FlowStoreType>((set, get) => ({
},
buildInfo: null,
});
const playgroundPage = get().playgroundPage;
get().setIsBuilding(true);
set({ flowBuildStatus: {} });
const currentFlow = useFlowsManagerStore.getState().currentFlow;
@ -926,201 +917,48 @@ const useFlowStore = create<FlowStoreType>((set, get) => ({
);
}
function validateSubgraph() {}
function handleBuildUpdate(
vertexBuildData: VertexBuildTypeAPI,
status: BuildStatus,
runId: string,
) {
if (vertexBuildData && vertexBuildData.inactivated_vertices) {
get().removeFromVerticesBuild(vertexBuildData.inactivated_vertices);
if (vertexBuildData.inactivated_vertices.length > 0) {
get().updateBuildStatus(
vertexBuildData.inactivated_vertices,
BuildStatus.INACTIVE,
);
}
}
// Each build gets its own AbortController so ``stopBuilding`` cancels
// only the in-flight run (re-using a controller across runs would leave
// it in the aborted state for the next call). The signal is passed to
// ``runFlowAGUI`` so Stop aborts the actual SSE request, not just the
// local build state.
const buildController = new AbortController();
get().setBuildController(buildController);
if (vertexBuildData.next_vertices_ids) {
// next_vertices_ids is a list of vertices that are going to be built next
// verticesLayers is a list of list of vertices ids, where each list is a layer of vertices
// we want to add a new layer (next_vertices_ids) to the list of layers (verticesLayers)
// and the values of next_vertices_ids to the list of vertices ids (verticesIds)
// const nextVertices will be the zip of vertexBuildData.next_vertices_ids and
// vertexBuildData.top_level_vertices
// the VertexLayerElementType as {id: next_vertices_id, layer: top_level_vertex}
// next_vertices_ids should be next_vertices_ids without the inactivated vertices
const next_vertices_ids = vertexBuildData.next_vertices_ids.filter(
(id) => !vertexBuildData.inactivated_vertices?.includes(id),
);
const top_level_vertices = vertexBuildData.top_level_vertices.filter(
(vertex) => !vertexBuildData.inactivated_vertices?.includes(vertex),
);
let nextVertices: VertexLayerElementType[] = zip(
next_vertices_ids,
top_level_vertices,
).map(([id, reference]) => ({ id: id!, reference }));
// Now we filter nextVertices to remove any vertices that are in verticesLayers
// because they are already being built
// each layer is a list of vertexlayerelementtypes
const lastLayer =
get().verticesBuild!.verticesLayers[
get().verticesBuild!.verticesLayers.length - 1
];
nextVertices = nextVertices.filter(
(vertexElement) =>
!lastLayer.some(
(layerElement) =>
layerElement.id === vertexElement.id &&
layerElement.reference === vertexElement.reference,
),
);
const newLayers = [
...get().verticesBuild!.verticesLayers,
nextVertices,
];
const newIds = [
...get().verticesBuild!.verticesIds,
...next_vertices_ids,
];
if (
ENABLE_DATASTAX_LANGFLOW &&
vertexBuildData?.id?.includes("AstraDB")
) {
const search_results: LogsLogType[] = Object.values(
vertexBuildData?.data?.logs?.search_results,
);
search_results.forEach((log) => {
if (
log.message.includes("Adding") &&
log.message.includes("documents") &&
log.message.includes("Vector Store")
) {
trackDataLoaded(
get().currentFlow?.id,
get().currentFlow?.name,
"AstraDB Vector Store",
vertexBuildData?.id,
);
}
});
}
get().updateVerticesBuild({
verticesIds: newIds,
verticesLayers: newLayers,
runId: runId,
verticesToRun: get().verticesBuild!.verticesToRun,
});
get().updateBuildStatus(top_level_vertices, BuildStatus.TO_BUILD);
}
get().addDataToFlowPool(
{ ...vertexBuildData, run_id: runId },
vertexBuildData.id,
);
if (status !== BuildStatus.ERROR) {
get().updateBuildStatus([vertexBuildData.id], status);
}
}
await buildFlowVerticesWithFallback({
session,
input_value,
files,
// Always run through the v2 workflows endpoint. Current frontend nodes
// + edges are sent so unsaved tweaks (dropdowns, text inputs) run as
// the user sees them.
await runFlowAGUI({
flowId: currentFlow!.id,
startNodeId,
stopNodeId,
onGetOrderSuccess: () => {},
onBuildComplete: (allNodesValid) => {
if (!silent) {
if (allNodesValid) {
get().setBuildInfo({ success: true });
}
}
get().updateEdgesRunningByNodes(
get().nodes.map((n) => n.id),
false,
);
get().setIsBuilding(false);
// Invalidate KB-related caches so any KnowledgeIngestion node
// that ran inside this build surfaces its updated stats / runs
// the next time the user opens the assets/knowledge-bases tab.
// Cheap when no subscribers are mounted; the queries only
// refetch if a component is actively reading them.
queryClient.invalidateQueries({ queryKey: ["useGetKnowledgeBases"] });
queryClient.invalidateQueries({ queryKey: ["useGetIngestionRuns"] });
queryClient.invalidateQueries({
queryKey: ["useGetKnowledgeBaseChunks"],
});
trackFlowBuild(get().currentFlow?.name ?? "Unknown", false, {
flowId: get().currentFlow?.id,
});
},
onBuildUpdate: handleBuildUpdate,
onBuildError: (title: string, list: string[], elementList) => {
const idList =
(elementList
?.map((element) => element.id)
.filter(Boolean) as string[]) ?? get().nodes.map((n) => n.id);
useFlowStore.getState().updateBuildStatus(idList, BuildStatus.ERROR);
const isCustomComponentBlocked = list.some((msg) =>
msg.toLowerCase().includes("custom components are not allowed"),
);
if (!isCustomComponentBlocked && get().componentsToUpdate.length > 0)
setErrorData({
title: i18n.t("errors.blockedComponents"),
});
get().updateEdgesRunningByNodes(
get().nodes.map((n) => n.id),
false,
);
get().setBuildInfo({ error: list, success: false });
useAlertStore.getState().addNotificationToHistory({
title: title,
type: "error",
list: list,
});
get().setIsBuilding(false);
get().buildController.abort();
trackFlowBuild(get().currentFlow?.name ?? "Unknown", true, {
flowId: get().currentFlow?.id,
error: list,
});
},
onBuildStart: (elementList) => {
const idList = elementList
// reference is the id of the vertex or the id of the parent in a group node
.map((element) => element.reference)
.filter(Boolean) as string[];
get().updateBuildStatus(idList, BuildStatus.BUILDING);
const edges = get().edges;
const newEdges = edges.map((edge) => {
if (
edge.data?.targetHandle &&
idList.includes(edge.data.targetHandle.id ?? "")
) {
edge.className = "ran";
}
return edge;
});
set({ edges: newEdges });
},
onValidateNodes: validateSubgraph,
nodes: get().nodes || undefined,
edges: get().edges || undefined,
logBuilds: get().onFlowPage,
playgroundPage,
eventDelivery,
message: input_value,
threadId: session,
startComponentId: startNodeId,
stopComponentId: stopNodeId,
flowData: { nodes: get().nodes, edges: get().edges },
files,
signal: buildController.signal,
});
// Invalidate KB-related caches so any KnowledgeIngestion node that ran
// inside this build surfaces its updated stats / runs the next time the
// user opens the assets/knowledge-bases tab. Cheap when no subscribers are
// mounted; the queries only refetch if a component is actively reading them.
queryClient.invalidateQueries({ queryKey: ["useGetKnowledgeBases"] });
queryClient.invalidateQueries({ queryKey: ["useGetIngestionRuns"] });
queryClient.invalidateQueries({ queryKey: ["useGetKnowledgeBaseChunks"] });
// Mirror the v1 build callbacks' analytics: every actual build attempt
// logs a flow-build event with success/error and (on failure) the error
// list. `runFlowAGUI` always resolves and writes the outcome into
// `buildInfo`, so reading it back is the cleanest success/error signal.
const finalBuildInfo = get().buildInfo;
const hasError = finalBuildInfo?.success === false;
trackFlowBuild(currentFlow?.name ?? "Unknown", hasError, {
flowId: currentFlow?.id,
...(hasError && finalBuildInfo?.error
? { error: finalBuildInfo.error }
: {}),
});
get().setIsBuilding(false);
get().revertBuiltStatusFromBuilding();
},
getFlow: () => {
return {

View File

@ -5,8 +5,12 @@ type TestFunction = (args: { page: Page }) => Promise<void>;
type TestConfig = Parameters<typeof test>[1];
/**
* Wraps a test function to run it with both streaming and polling event delivery modes.
* Adds a 3-second delay between test runs to ensure proper separation.
* Shim that historically expanded a spec into one test per v1
* ``event_delivery`` mode (streaming / polling / direct). The v2 workflows
* endpoint replaced the three modes with a single AG-UI SSE path, so the
* wrapper now just registers the test once. Kept as a no-op so existing
* spec call sites don't all have to change in this PR; a follow-up can
* delete the wrapper and inline ``test`` at every call site.
*
* @param title The test title
* @param config The test configuration (tags, etc)
@ -17,20 +21,7 @@ export function withEventDeliveryModes(
config: TestConfig,
testFn: TestFunction,
) {
const eventDeliveryModes = ["streaming", "polling", "direct"] as const;
for (const eventDelivery of eventDeliveryModes) {
test(`${title} - ${eventDelivery}`, config, async ({ page }) => {
// Intercept the config request and modify the event_delivery setting
await page.route("**/api/v1/config", async (route) => {
const response = await route.fetch();
const json = await response.json();
json.event_delivery = eventDelivery;
await route.fulfill({ response, json });
});
// Run the original test function
await testFn({ page });
});
}
test(title, config, async ({ page }) => {
await testFn({ page });
});
}

View File

@ -126,6 +126,187 @@ class WorkflowExecutionRequest(BaseModel):
)
class WorkflowMode(str, Enum):
"""Execution mode for a v2 workflow run."""
SYNC = "sync"
STREAM = "stream"
BACKGROUND = "background"
class WorkflowRunRequest(BaseModel):
"""Request schema for ``POST /api/v2/workflows`` (v2 native body).
First-class fields for everything callers actually configure when running a
flow. Streaming protocol is selected by ``stream_protocol``; the endpoint
validates it against the live adapter registry and returns 422 with the
available list when unknown.
"""
flow_id: str = Field(..., description="UUID of the flow to run.")
input_value: str = Field("", description="Chat-style input value.")
tweaks: dict[str, Any] = Field(
default_factory=dict,
description="Per-component parameter overrides keyed by component id.",
)
session_id: str | None = Field(
None,
description="When set, message memory and chat history scope to this session.",
)
mode: WorkflowMode = Field(
WorkflowMode.SYNC,
description=(
"Execution mode. ``sync`` runs inline and returns the aggregated "
"response; ``stream`` returns SSE; ``background`` queues a job."
),
)
stream_protocol: str = Field(
"langflow",
description=(
"Wire protocol for streaming events. Defaults to ``langflow`` "
"(raw EventManager payloads). ``agui`` emits AG-UI events. Unknown "
"values return 422 with the available list. Ignored when mode=sync."
),
)
data: dict[str, Any] | None = Field(
None,
description=(
"Optional live-canvas override of the flow's nodes/edges; takes priority over the saved flow data."
),
)
files: list[str] | None = Field(
None,
description="Optional list of pre-uploaded file paths to attach to the run.",
)
start_component_id: str | None = Field(None, description="Partial-run start component id.")
stop_component_id: str | None = Field(None, description="Partial-run stop component id.")
globals: dict[GlobalVarKey, GlobalVarValue] = Field(
default_factory=dict,
description=(
"Request-level global variables made available to workflow components. "
"Keys may use any printable string up to "
f"{GLOBAL_KEY_MAX_LEN} chars; values are capped at "
f"{GLOBAL_VALUE_MAX_LEN} chars. Body globals always win over the "
"legacy ``X-LANGFLOW-GLOBAL-VAR-*`` headers."
),
)
model_config = ConfigDict(
extra="forbid",
json_schema_extra={
"examples": [
{
"flow_id": "67ccd2be-17f0-8190-81ff-3bb2cf6508e6",
"input_value": "Hello, how can you help me today?",
},
{
"flow_id": "67ccd2be-17f0-8190-81ff-3bb2cf6508e6",
"input_value": "Stream this conversation",
"mode": "stream",
},
{
"flow_id": "67ccd2be-17f0-8190-81ff-3bb2cf6508e6",
"input_value": "Drive the canvas",
"mode": "stream",
"stream_protocol": "agui",
"session_id": "session-123",
},
{
"flow_id": "67ccd2be-17f0-8190-81ff-3bb2cf6508e6",
"input_value": "Process in the background",
"mode": "background",
},
],
},
)
@model_validator(mode="after")
def validate_flow_id(self) -> WorkflowRunRequest:
"""Reject non-UUID ``flow_id`` early so the endpoint can trust it."""
uuid_validator(self.flow_id, message="Invalid flow_id, must be a UUID")
return self
class PublicWorkflowRunRequest(BaseModel):
"""Request schema for ``POST /api/v2/workflows/public``.
Narrower than ``WorkflowRunRequest`` so the public-flow surface stays
locked down. Notably absent vs the regular body:
- ``data`` — visitors must never override the stored flow definition.
- ``tweaks`` — visitors must never override component parameters.
The endpoint enforces the additional CVE mitigations that the regular
endpoint does not need:
- ``access_type == PUBLIC`` gate (others 403).
- ``virtual_flow_id = uuid5(identifier, flow_id)`` so messages stay
isolated per visitor.
- Session string namespaced under the virtual flow id
(CVE-2026-33017).
- File-path validation (GHSA-rcjh-r59h-gq37).
- Owner impersonation: the run executes under the flow owner's
permissions, never the visitor's.
"""
flow_id: str = Field(..., description="UUID of the public flow to run.")
input_value: str = Field("", description="Chat-style input value.")
session_id: str | None = Field(
None,
description=("Optional caller session. Always namespaced under the visitor's virtual flow id by the endpoint."),
)
mode: Literal[WorkflowMode.STREAM] = Field(
WorkflowMode.STREAM,
description=(
"Always ``stream``. Sync/background modes would widen the public "
"attack surface (job polling, owner impersonation persists across "
"queue boundaries) so the schema rejects them at the wire."
),
)
stream_protocol: str = Field(
"langflow",
description=(
"Wire protocol for streaming events. Defaults to ``langflow`` "
"(raw EventManager payloads). ``agui`` emits AG-UI events. Unknown "
"values return 422 with the available list."
),
)
files: list[str] | None = Field(
None,
description=(
"Optional list of pre-uploaded file paths. Each path must be "
"scoped to this flow's own storage namespace; the endpoint "
"rejects path traversal or cross-flow references."
),
)
start_component_id: str | None = Field(None, description="Partial-run start component id.")
stop_component_id: str | None = Field(None, description="Partial-run stop component id.")
model_config = ConfigDict(
extra="forbid",
json_schema_extra={
"examples": [
{
"flow_id": "67ccd2be-17f0-8190-81ff-3bb2cf6508e6",
"input_value": "Hello from the shareable playground",
},
{
"flow_id": "67ccd2be-17f0-8190-81ff-3bb2cf6508e6",
"input_value": "Stream the response",
"stream_protocol": "agui",
"session_id": "thread-A",
},
],
},
)
@model_validator(mode="after")
def validate_flow_id(self) -> PublicWorkflowRunRequest:
"""Reject non-UUID ``flow_id`` early so the endpoint can trust it."""
uuid_validator(self.flow_id, message="Invalid flow_id, must be a UUID")
return self
class WorkflowExecutionResponse(BaseModel):
"""Synchronous workflow execution response."""

View File

@ -564,7 +564,15 @@ class Settings(BaseSettings):
@field_validator("cors_origins", mode="before")
@classmethod
def validate_cors_origins(cls, value):
"""Convert comma-separated string to list if needed."""
"""Convert comma-separated string to list if needed.
Pydantic-settings on Python 3.14 parses the env var "*" into ["*"]
before this validator runs (the union list[str] | str resolves
differently). Collapse that back to the bare-string wildcard so
downstream consumers see the same shape on every Python version.
"""
if isinstance(value, list) and value == ["*"]:
return "*"
if isinstance(value, str) and value != "*":
if "," in value:
# Convert comma-separated string to list

View File

@ -0,0 +1,97 @@
"""Tests for the v2 ``WorkflowRunRequest`` body schema."""
from __future__ import annotations
import pytest
from lfx.schema.workflow import WorkflowMode, WorkflowRunRequest
from pydantic import ValidationError
_VALID_UUID = "67ccd2be-17f0-8190-81ff-3bb2cf6508e6"
class TestDefaults:
"""A minimal body should auto-populate the awesome-DX defaults."""
def test_minimal_body_defaults_to_sync_langflow(self):
req = WorkflowRunRequest(flow_id=_VALID_UUID)
assert req.flow_id == _VALID_UUID
assert req.input_value == ""
assert req.tweaks == {}
assert req.session_id is None
assert req.mode is WorkflowMode.SYNC
assert req.stream_protocol == "langflow"
assert req.data is None
assert req.files is None
assert req.start_component_id is None
assert req.stop_component_id is None
def test_mode_accepts_string_value(self):
req = WorkflowRunRequest(flow_id=_VALID_UUID, mode="stream")
assert req.mode is WorkflowMode.STREAM
def test_mode_rejects_unknown_value(self):
with pytest.raises(ValidationError) as exc:
WorkflowRunRequest(flow_id=_VALID_UUID, mode="async")
assert "mode" in str(exc.value)
def test_stream_protocol_accepts_any_string_for_registry_lookup(self):
"""The schema doesn't constrain the protocol so adapters can register at runtime.
Endpoint-level dispatch is responsible for the 422 with the available list.
"""
req = WorkflowRunRequest(
flow_id=_VALID_UUID,
mode="stream",
stream_protocol="something-not-registered-yet",
)
assert req.stream_protocol == "something-not-registered-yet"
class TestFlowIdValidation:
"""``flow_id`` must be a UUID-formatted string."""
def test_missing_flow_id_raises(self):
with pytest.raises(ValidationError) as exc:
WorkflowRunRequest()
assert "flow_id" in str(exc.value)
def test_non_uuid_flow_id_raises(self):
with pytest.raises(ValidationError) as exc:
WorkflowRunRequest(flow_id="not-a-uuid")
assert "flow_id" in str(exc.value).lower() or "uuid" in str(exc.value).lower()
def test_uppercase_uuid_accepted(self):
upper = _VALID_UUID.upper()
req = WorkflowRunRequest(flow_id=upper)
assert req.flow_id == upper
class TestExtraForbid:
"""Unknown body keys must be rejected so typos surface as 422 instead of silent drops."""
def test_unknown_field_rejected(self):
with pytest.raises(ValidationError) as exc:
WorkflowRunRequest(flow_id=_VALID_UUID, mod="sync")
assert "mod" in str(exc.value)
class TestRoundTripsWithRichBody:
"""A fully populated body round-trips through ``model_dump`` / ``model_validate``."""
def test_full_body_round_trip(self):
body = {
"flow_id": _VALID_UUID,
"input_value": "Hello!",
"tweaks": {"ChatInput-abc": {"some_param": "value"}},
"session_id": "session-123",
"mode": "stream",
"stream_protocol": "agui",
"data": {"nodes": [], "edges": []},
"files": ["/tmp/a.txt", "/tmp/b.png"],
"start_component_id": "ChatInput-abc",
"stop_component_id": "ChatOutput-xyz",
"globals": {"API_TOKEN": "secret-123"},
}
req = WorkflowRunRequest.model_validate(body)
dumped = req.model_dump(mode="json")
assert dumped == body

2
uv.lock generated
View File

@ -7603,6 +7603,7 @@ name = "langflow-base"
version = "0.10.0"
source = { editable = "src/backend/base" }
dependencies = [
{ name = "ag-ui-protocol" },
{ name = "aiofile", version = "3.9.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
{ name = "aiofile", version = "3.11.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" },
{ name = "aiofiles" },
@ -8303,6 +8304,7 @@ dev = [
[package.metadata]
requires-dist = [
{ name = "ag-ui-protocol", specifier = "==0.1.18" },
{ name = "ag2", marker = "extra == 'ag2'", specifier = ">=0.1.0" },
{ name = "agent-lifecycle-toolkit", marker = "(python_full_version < '3.14' and platform_machine != 'x86_64' and extra == 'altk') or (python_full_version < '3.14' and sys_platform != 'darwin' and extra == 'altk')", specifier = ">=0.10.1,<1.0" },
{ name = "aioboto3", marker = "extra == 'aioboto3'", specifier = ">=15.2.0,<16.0.0" },