mirror of
https://github.com/langflow-ai/langflow.git
synced 2026-07-23 19:24:11 +08:00
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:
@ -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",
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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.
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
118
src/backend/base/langflow/api/v2/adapters/__init__.py
Normal file
118
src/backend/base/langflow/api/v2/adapters/__init__.py
Normal 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
|
||||
66
src/backend/base/langflow/api/v2/adapters/agui.py
Normal file
66
src/backend/base/langflow/api/v2/adapters/agui.py
Normal 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)
|
||||
50
src/backend/base/langflow/api/v2/adapters/langflow.py
Normal file
50
src/backend/base/langflow/api/v2/adapters/langflow.py
Normal 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)
|
||||
302
src/backend/base/langflow/api/v2/agui_translator.py
Normal file
302
src/backend/base/langflow/api/v2/agui_translator.py
Normal 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]
|
||||
@ -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
179
src/backend/base/langflow/api/v2/workflow_public.py
Normal file
179
src/backend/base/langflow/api/v2/workflow_public.py
Normal 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"},
|
||||
)
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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)],
|
||||
|
||||
@ -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",
|
||||
|
||||
0
src/backend/tests/unit/api/v2/adapters/__init__.py
Normal file
0
src/backend/tests/unit/api/v2/adapters/__init__.py
Normal file
179
src/backend/tests/unit/api/v2/adapters/test_agui_adapter.py
Normal file
179
src/backend/tests/unit/api/v2/adapters/test_agui_adapter.py
Normal 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 == []
|
||||
114
src/backend/tests/unit/api/v2/adapters/test_langflow_adapter.py
Normal file
114
src/backend/tests/unit/api/v2/adapters/test_langflow_adapter.py
Normal 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
|
||||
123
src/backend/tests/unit/api/v2/adapters/test_registry.py
Normal file
123
src/backend/tests/unit/api/v2/adapters/test_registry.py
Normal 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"}'
|
||||
700
src/backend/tests/unit/api/v2/test_agui_translator.py
Normal file
700
src/backend/tests/unit/api/v2/test_agui_translator.py
Normal 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
|
||||
@ -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
1475
src/backend/tests/unit/api/v2/test_workflow_agui.py
Normal file
1475
src/backend/tests/unit/api/v2/test_workflow_agui.py
Normal file
File diff suppressed because it is too large
Load Diff
381
src/backend/tests/unit/api/v2/test_workflow_public.py
Normal file
381
src/backend/tests/unit/api/v2/test_workflow_public.py
Normal 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"
|
||||
106
src/frontend/package-lock.json
generated
106
src/frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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");
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@ -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([]);
|
||||
});
|
||||
});
|
||||
191
src/frontend/src/controllers/API/agui/run-agent.ts
Normal file
191
src/frontend/src/controllers/API/agui/run-agent.ts
Normal 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;
|
||||
}
|
||||
323
src/frontend/src/controllers/API/agui/run-flow-bridge.ts
Normal file
323
src/frontend/src/controllers/API/agui/run-flow-bridge.ts
Normal 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();
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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 });
|
||||
});
|
||||
}
|
||||
|
||||
@ -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."""
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
97
src/lfx/tests/unit/schema/test_workflow_run_request.py
Normal file
97
src/lfx/tests/unit/schema/test_workflow_run_request.py
Normal 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
2
uv.lock
generated
@ -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" },
|
||||
|
||||
Reference in New Issue
Block a user