mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
feat(workflow): support human input in loop and iteration (#39243)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
@@ -70,6 +70,7 @@ def init_tool_node(config: dict):
|
||||
tool_file_manager=tool_file_manager,
|
||||
runtime=DifyToolNodeRuntime(init_params.run_context),
|
||||
)
|
||||
node.bind_execution_id(str(uuid.uuid4()))
|
||||
return node
|
||||
|
||||
|
||||
|
||||
+5
-11
@@ -17,7 +17,6 @@ These tests use TestContainers to spin up real services for integration testing,
|
||||
providing more reliable and realistic test scenarios than mocks.
|
||||
"""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from time import time
|
||||
from unittest.mock import Mock
|
||||
@@ -237,12 +236,12 @@ class TestPauseStatePersistenceLayerTestContainers:
|
||||
|
||||
# Create LLM usage
|
||||
llm_usage = LLMUsage.empty_usage()
|
||||
llm_usage.total_tokens = total_tokens
|
||||
|
||||
# Create graph runtime state
|
||||
graph_runtime_state = GraphRuntimeState(
|
||||
variable_pool=variable_pool,
|
||||
start_at=start_at,
|
||||
total_tokens=total_tokens,
|
||||
llm_usage=llm_usage,
|
||||
outputs=outputs or {},
|
||||
node_run_steps=node_run_steps,
|
||||
@@ -366,9 +365,6 @@ class TestPauseStatePersistenceLayerTestContainers:
|
||||
resumption_context = WorkflowResumptionContext.loads(storage_content)
|
||||
assert resumption_context.version == "1"
|
||||
assert resumption_context.serialized_graph_runtime_state == graph_runtime_state.dumps()
|
||||
expected_state = json.loads(graph_runtime_state.dumps())
|
||||
actual_state = json.loads(resumption_context.serialized_graph_runtime_state)
|
||||
assert actual_state == expected_state
|
||||
persisted_entity = resumption_context.get_generate_entity()
|
||||
assert isinstance(persisted_entity, WorkflowAppGenerateEntity)
|
||||
assert persisted_entity.workflow_execution_id == self.test_workflow_run_id
|
||||
@@ -414,13 +410,11 @@ class TestPauseStatePersistenceLayerTestContainers:
|
||||
|
||||
state_bytes = pause_entity.get_state()
|
||||
resumption_context = WorkflowResumptionContext.loads(state_bytes.decode())
|
||||
retrieved_state = json.loads(resumption_context.serialized_graph_runtime_state)
|
||||
expected_state = json.loads(graph_runtime_state.dumps())
|
||||
retrieved_state = GraphRuntimeState.from_snapshot(resumption_context.serialized_graph_runtime_state)
|
||||
|
||||
assert retrieved_state == expected_state
|
||||
assert retrieved_state["outputs"] == complex_outputs
|
||||
assert retrieved_state["total_tokens"] == 250
|
||||
assert retrieved_state["node_run_steps"] == 10
|
||||
assert retrieved_state.outputs == complex_outputs
|
||||
assert retrieved_state.total_tokens == 250
|
||||
assert retrieved_state.node_run_steps == 10
|
||||
assert resumption_context.get_generate_entity().workflow_execution_id == self.test_workflow_run_id
|
||||
|
||||
def test_database_transaction_handling(self, db_session_with_containers: Session):
|
||||
|
||||
@@ -210,7 +210,7 @@ class TestEnumText:
|
||||
|
||||
assert str(exc.value) == "'invalid' is not a valid _UserType"
|
||||
|
||||
def test_select_legacy_model_type_values(self, engine_with_containers: Engine):
|
||||
def test_select_rejects_legacy_model_type_values(self, engine_with_containers: Engine):
|
||||
insertion_sql = """
|
||||
INSERT INTO enum_text_legacy_model_type_test (id, model_type) VALUES
|
||||
(1, 'text-generation'),
|
||||
@@ -221,11 +221,9 @@ class TestEnumText:
|
||||
session.execute(sa.text(insertion_sql))
|
||||
session.commit()
|
||||
|
||||
with Session(engine_with_containers) as session:
|
||||
records = session.scalars(select(_LegacyModelTypeRecord).order_by(_LegacyModelTypeRecord.id)).all()
|
||||
for record_id, legacy_value in enumerate(("text-generation", "embeddings", "reranking"), 1):
|
||||
with pytest.raises(ValueError) as exc:
|
||||
with Session(engine_with_containers) as session:
|
||||
session.scalar(select(_LegacyModelTypeRecord).where(_LegacyModelTypeRecord.id == record_id))
|
||||
|
||||
assert [record.model_type for record in records] == [
|
||||
ModelType.LLM,
|
||||
ModelType.TEXT_EMBEDDING,
|
||||
ModelType.RERANK,
|
||||
]
|
||||
assert str(exc.value) == f"'{legacy_value}' is not a valid ModelType"
|
||||
|
||||
@@ -83,6 +83,7 @@ def test_dify_config(monkeypatch: pytest.MonkeyPatch):
|
||||
assert config.AGENT_SHELL_ENABLED is True
|
||||
assert config.SENTRY_TRACES_SAMPLE_RATE == 1.0
|
||||
assert config.TEMPLATE_TRANSFORM_MAX_LENGTH == 400_000
|
||||
assert config.GRAPH_ENGINE_SCALE_UP_THRESHOLD == 0
|
||||
|
||||
# annotated field with custom configured value
|
||||
assert config.HTTP_REQUEST_MAX_READ_TIMEOUT == 300
|
||||
|
||||
@@ -4,7 +4,7 @@ import json
|
||||
from datetime import UTC, datetime
|
||||
from inspect import unwrap
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import ANY, Mock
|
||||
|
||||
import pytest
|
||||
from flask import Flask, Response
|
||||
@@ -18,6 +18,7 @@ from controllers.console.human_input_form import (
|
||||
WorkflowResponseConverter,
|
||||
_jsonify_form_definition,
|
||||
)
|
||||
from core.workflow.human_input_policy import HumanInputSurface
|
||||
from models.account import AccountStatus
|
||||
from models.enums import CreatorUserRole
|
||||
from models.human_input import RecipientType
|
||||
@@ -344,3 +345,62 @@ def test_workflow_events_finished(app: Flask, monkeypatch: pytest.MonkeyPatch) -
|
||||
|
||||
assert response.mimetype == "text/event-stream"
|
||||
assert "data" in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_workflow_events_snapshot_can_continue_across_pauses(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
workflow_run = SimpleNamespace(
|
||||
id="run-1",
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by="user-1",
|
||||
tenant_id="t1",
|
||||
app_id="app-1",
|
||||
finished_at=None,
|
||||
)
|
||||
app_model = SimpleNamespace(mode=AppMode.WORKFLOW)
|
||||
|
||||
class _RepoStub:
|
||||
def get_workflow_run_by_id_and_tenant_id(self, **_kwargs):
|
||||
return workflow_run
|
||||
|
||||
workflow_generator = Mock()
|
||||
workflow_generator.convert_to_event_stream.return_value = iter(["data: snapshot\n\n"])
|
||||
snapshot_builder = Mock(return_value=["snapshot-events"])
|
||||
|
||||
monkeypatch.setattr(
|
||||
DifyAPIRepositoryFactory,
|
||||
"create_api_workflow_run_repository",
|
||||
lambda *_args, **_kwargs: _RepoStub(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.human_input_form._retrieve_app_for_workflow_run",
|
||||
lambda *_args, **_kwargs: app_model,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.human_input_form.WorkflowAppGenerator",
|
||||
lambda: workflow_generator,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"controllers.console.human_input_form.build_workflow_event_stream",
|
||||
snapshot_builder,
|
||||
)
|
||||
monkeypatch.setattr("controllers.console.human_input_form.db", SimpleNamespace(engine=object()))
|
||||
|
||||
api = ConsoleWorkflowEventsApi()
|
||||
handler = unwrap(api.get)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow/run-1/events?include_state_snapshot=true&continue_on_pause=true",
|
||||
method="GET",
|
||||
):
|
||||
response = handler(api, "t1", SimpleNamespace(id="user-1"), workflow_run_id="run-1")
|
||||
|
||||
assert response.get_data(as_text=True) == "data: snapshot\n\n"
|
||||
snapshot_builder.assert_called_once_with(
|
||||
app_mode=AppMode.WORKFLOW,
|
||||
workflow_run=workflow_run,
|
||||
tenant_id="t1",
|
||||
app_id="app-1",
|
||||
session_maker=ANY,
|
||||
human_input_surface=HumanInputSurface.CONSOLE,
|
||||
close_on_pause=False,
|
||||
)
|
||||
|
||||
@@ -271,8 +271,7 @@ def _build_resumption_context(task_id: str) -> WorkflowResumptionContext:
|
||||
workflow_execution_id="run-1",
|
||||
)
|
||||
runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0)
|
||||
runtime_state.register_paused_node("node-1")
|
||||
runtime_state.outputs = {"result": "value"}
|
||||
runtime_state.set_output("result", "value")
|
||||
wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity)
|
||||
return WorkflowResumptionContext(
|
||||
generate_entity=wrapper,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import ANY, MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
@@ -11,6 +11,7 @@ from flask import Flask
|
||||
from controllers.common.errors import NotFoundError
|
||||
from controllers.web.workflow_events import WorkflowEventsApi
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import AppMode
|
||||
|
||||
|
||||
def _workflow_app() -> SimpleNamespace:
|
||||
@@ -125,3 +126,39 @@ class TestWorkflowEventsApi:
|
||||
response = WorkflowEventsApi().get(_workflow_app(), _end_user(), "run-1")
|
||||
|
||||
assert response.mimetype == "text/event-stream"
|
||||
|
||||
@patch("controllers.web.workflow_events.DifyAPIRepositoryFactory")
|
||||
@patch("controllers.web.workflow_events.db")
|
||||
def test_snapshot_stream_can_continue_across_pauses(
|
||||
self, mock_db: MagicMock, mock_factory: MagicMock, app: Flask, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
mock_db.engine = "engine"
|
||||
run = SimpleNamespace(
|
||||
id="run-1",
|
||||
app_id="app-1",
|
||||
created_by_role=CreatorUserRole.END_USER,
|
||||
created_by="eu-1",
|
||||
finished_at=None,
|
||||
)
|
||||
mock_repo = MagicMock()
|
||||
mock_repo.get_workflow_run_by_id_and_tenant_id.return_value = run
|
||||
mock_factory.create_api_workflow_run_repository.return_value = mock_repo
|
||||
|
||||
workflow_generator = Mock()
|
||||
workflow_generator.convert_to_event_stream.return_value = iter(["data: snapshot\n\n"])
|
||||
snapshot_builder = Mock(return_value=["snapshot-events"])
|
||||
monkeypatch.setattr("controllers.web.workflow_events.WorkflowAppGenerator", lambda: workflow_generator)
|
||||
monkeypatch.setattr("controllers.web.workflow_events.build_workflow_event_stream", snapshot_builder)
|
||||
|
||||
with app.test_request_context("/workflow/run-1/events?include_state_snapshot=true&continue_on_pause=true"):
|
||||
response = WorkflowEventsApi().get(_workflow_app(), _end_user(), "run-1")
|
||||
|
||||
assert response.get_data(as_text=True) == "data: snapshot\n\n"
|
||||
snapshot_builder.assert_called_once_with(
|
||||
app_mode=AppMode.WORKFLOW,
|
||||
workflow_run=run,
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
session_maker=ANY,
|
||||
close_on_pause=False,
|
||||
)
|
||||
|
||||
@@ -748,11 +748,13 @@ class TestAdvancedChatAppGeneratorInternals:
|
||||
|
||||
monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.preserve_flask_contexts", _fake_context)
|
||||
|
||||
workflow = SimpleNamespace(id="workflow-id", tenant_id="tenant", app_id="app")
|
||||
|
||||
class _Session:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.scalar = MagicMock(
|
||||
side_effect=[
|
||||
SimpleNamespace(id="workflow-id", tenant_id="tenant", app_id="app"),
|
||||
workflow,
|
||||
SimpleNamespace(id="app"),
|
||||
]
|
||||
)
|
||||
@@ -772,6 +774,8 @@ class TestAdvancedChatAppGeneratorInternals:
|
||||
|
||||
monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.Session", _Session)
|
||||
monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.AdvancedChatAppRunner", _Runner)
|
||||
restore_workflow_run_graph = MagicMock()
|
||||
monkeypatch.setattr(generator, "_restore_workflow_run_graph", restore_workflow_run_graph)
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.advanced_chat.app_generator.db",
|
||||
SimpleNamespace(engine=object(), session=SimpleNamespace(close=lambda: None)),
|
||||
@@ -788,10 +792,12 @@ class TestAdvancedChatAppGeneratorInternals:
|
||||
workflow_execution_repository=SimpleNamespace(),
|
||||
workflow_node_execution_repository=SimpleNamespace(),
|
||||
graph_engine_layers=(),
|
||||
graph_runtime_state=None,
|
||||
graph_runtime_state=SimpleNamespace(),
|
||||
)
|
||||
|
||||
queue_manager.publish_error.assert_not_called()
|
||||
assert restore_workflow_run_graph.call_args.kwargs["workflow"] is workflow
|
||||
assert restore_workflow_run_graph.call_args.kwargs["workflow_run_id"] == "run-id"
|
||||
|
||||
def test_generate_worker_handles_validation_error(self, monkeypatch: pytest.MonkeyPatch):
|
||||
generator = AdvancedChatAppGenerator()
|
||||
|
||||
+2
-1
@@ -56,6 +56,7 @@ from core.workflow.nodes.human_input.pause_reason import DifyHITLEventType
|
||||
from core.workflow.system_variables import build_system_variables
|
||||
from graphon.enums import BuiltinNodeTypes
|
||||
from graphon.file import FileTransferMethod, FileType
|
||||
from graphon.model_runtime.entities.llm_entities import LLMUsage
|
||||
from graphon.runtime import GraphRuntimeState, VariablePool
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.enums import MessageStatus
|
||||
@@ -174,7 +175,7 @@ class TestAdvancedChatGenerateTaskPipeline:
|
||||
variables=build_system_variables(workflow_execution_id="run-id"),
|
||||
),
|
||||
start_at=0.0,
|
||||
total_tokens=7,
|
||||
llm_usage=LLMUsage.empty_usage().model_copy(update={"total_tokens": 7}),
|
||||
node_run_steps=3,
|
||||
)
|
||||
|
||||
|
||||
@@ -1,10 +1,39 @@
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import inspect
|
||||
|
||||
from core.app.apps.base_app_generator import BaseAppGenerator
|
||||
from graphon.variables.input_entities import VariableEntity, VariableEntityType
|
||||
from models import Workflow, WorkflowRun
|
||||
|
||||
|
||||
def test_restore_workflow_run_graph():
|
||||
workflow = Workflow(graph='{"nodes": [{"id": "edited"}]}')
|
||||
session = SimpleNamespace(get=Mock(return_value=SimpleNamespace(graph='{"nodes": [{"id": "paused"}]}')))
|
||||
|
||||
BaseAppGenerator._restore_workflow_run_graph(session=session, workflow=workflow, workflow_run_id="run-id")
|
||||
|
||||
session.get.assert_called_once_with(WorkflowRun, "run-id")
|
||||
assert workflow.graph == '{"nodes": [{"id": "paused"}]}'
|
||||
assert not inspect(workflow).attrs.graph.history.has_changes()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("workflow_run_id", "workflow_run"),
|
||||
[(None, None), ("run-id", None), ("run-id", SimpleNamespace(graph=None))],
|
||||
)
|
||||
def test_restore_workflow_run_graph_requires_persisted_snapshot(workflow_run_id, workflow_run):
|
||||
session = SimpleNamespace(get=Mock(return_value=workflow_run))
|
||||
|
||||
with pytest.raises(ValueError):
|
||||
BaseAppGenerator._restore_workflow_run_graph(
|
||||
session=session,
|
||||
workflow=Workflow(graph="{}"),
|
||||
workflow_run_id=workflow_run_id,
|
||||
)
|
||||
|
||||
|
||||
def test_validate_inputs_with_zero():
|
||||
|
||||
@@ -6,7 +6,12 @@ from core.app.apps.base_app_queue_manager import PublishFrom
|
||||
from core.app.apps.exc import GenerateTaskStoppedError
|
||||
from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.app.entities.queue_entities import QueueErrorEvent, QueueMessageEndEvent, QueueStopEvent
|
||||
from core.app.entities.queue_entities import (
|
||||
QueueErrorEvent,
|
||||
QueueMessageEndEvent,
|
||||
QueueStopEvent,
|
||||
QueueWorkflowPausedEvent,
|
||||
)
|
||||
|
||||
|
||||
class TestMessageBasedAppQueueManager:
|
||||
@@ -63,3 +68,21 @@ class TestMessageBasedAppQueueManager:
|
||||
manager._publish(QueueMessageEndEvent(), PublishFrom.TASK_PIPELINE)
|
||||
|
||||
assert manager._q.qsize() == 1
|
||||
|
||||
def test_publish_pause_event_stops_listener_without_aborting_execution(self):
|
||||
with patch("core.app.apps.base_app_queue_manager.redis_client") as mock_redis:
|
||||
mock_redis.setex.return_value = True
|
||||
manager = MessageBasedAppQueueManager(
|
||||
task_id="t1",
|
||||
user_id="u1",
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
conversation_id="c1",
|
||||
app_mode="advanced-chat",
|
||||
message_id="m1",
|
||||
)
|
||||
manager.stop_listen = Mock()
|
||||
manager._is_stopped = Mock(return_value=False)
|
||||
|
||||
manager._publish(QueueWorkflowPausedEvent(), PublishFrom.APPLICATION_MANAGER)
|
||||
|
||||
manager.stop_listen.assert_called_once_with(execution_terminal=True)
|
||||
|
||||
@@ -334,7 +334,6 @@ class TestWorkflowBasedAppRunner:
|
||||
variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()),
|
||||
start_at=0.0,
|
||||
)
|
||||
graph_runtime_state.register_paused_node("node-1")
|
||||
workflow_entry = SimpleNamespace(graph_engine=SimpleNamespace(graph_runtime_state=graph_runtime_state))
|
||||
|
||||
emails: list[dict] = []
|
||||
|
||||
@@ -20,9 +20,6 @@ class _DummyQueueManager:
|
||||
class _DummyRuntimeState:
|
||||
variable_pool = object()
|
||||
|
||||
def get_paused_nodes(self):
|
||||
return ["node-1"]
|
||||
|
||||
|
||||
class _DummyGraphEngine:
|
||||
def __init__(self):
|
||||
|
||||
@@ -130,6 +130,7 @@ def test_single_node_run_validates_target_node_config(monkeypatch: pytest.Monkey
|
||||
"type": "loop",
|
||||
"title": "Loop",
|
||||
"loop_count": 1,
|
||||
"start_node_id": "loop-start",
|
||||
"break_conditions": [],
|
||||
"logical_operator": "and",
|
||||
},
|
||||
|
||||
@@ -40,9 +40,6 @@ class _RecordingWorkflowAppRunner(WorkflowAppRunner):
|
||||
class _FakeRuntimeState:
|
||||
variable_pool = object()
|
||||
|
||||
def get_paused_nodes(self):
|
||||
return ["node-pause-1"]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sqlite_pause_session(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> Session:
|
||||
@@ -140,6 +137,7 @@ def test_graph_run_paused_event_emits_queue_pause_event(monkeypatch: pytest.Monk
|
||||
"core.app.apps.workflow_app_runner.enrich_graph_pause_reasons",
|
||||
lambda **_: [enriched_reason],
|
||||
)
|
||||
monkeypatch.setattr("core.app.apps.workflow_app_runner.dispatch_human_input_email_task", MagicMock())
|
||||
|
||||
runner._handle_event(workflow_entry, event)
|
||||
|
||||
@@ -148,7 +146,7 @@ def test_graph_run_paused_event_emits_queue_pause_event(monkeypatch: pytest.Monk
|
||||
assert isinstance(queue_event, QueueWorkflowPausedEvent)
|
||||
assert queue_event.reasons == [enriched_reason]
|
||||
assert queue_event.outputs == {"foo": "bar"}
|
||||
assert queue_event.paused_nodes == ["node-pause-1"]
|
||||
assert queue_event.paused_nodes == ["node-human"]
|
||||
|
||||
|
||||
def _build_converter(*, invoke_from: InvokeFrom = InvokeFrom.SERVICE_API):
|
||||
|
||||
@@ -543,6 +543,8 @@ class TestWorkflowAppGeneratorWorker:
|
||||
lambda self, *, session, workflow: workflow,
|
||||
)
|
||||
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppRunner", _Runner)
|
||||
restore_workflow_run_graph = Mock()
|
||||
monkeypatch.setattr(generator, "_restore_workflow_run_graph", restore_workflow_run_graph)
|
||||
|
||||
app_config = WorkflowUIBasedAppConfig(
|
||||
tenant_id="tenant",
|
||||
@@ -574,6 +576,12 @@ class TestWorkflowAppGeneratorWorker:
|
||||
variable_loader=SimpleNamespace(),
|
||||
workflow_execution_repository=SimpleNamespace(),
|
||||
workflow_node_execution_repository=SimpleNamespace(),
|
||||
graph_runtime_state=SimpleNamespace(),
|
||||
)
|
||||
|
||||
assert runner_kwargs["system_user_id"] == "session-id"
|
||||
restore_workflow_run_graph.assert_called_once_with(
|
||||
session=session,
|
||||
workflow=workflow,
|
||||
workflow_run_id="run-id",
|
||||
)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from core.app.apps.base_app_queue_manager import PublishFrom
|
||||
from core.app.apps.workflow.app_queue_manager import WorkflowAppQueueManager
|
||||
@@ -41,6 +41,19 @@ class TestWorkflowAppQueueManager:
|
||||
|
||||
manager._publish(QueuePingEvent(), PublishFrom.TASK_PIPELINE)
|
||||
|
||||
def test_publish_pause_event_stops_listener_without_aborting_execution(self):
|
||||
manager = WorkflowAppQueueManager(
|
||||
task_id="task",
|
||||
user_id="user",
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
app_mode="workflow",
|
||||
)
|
||||
manager.stop_listen = Mock()
|
||||
|
||||
manager._publish(QueueWorkflowPausedEvent(), PublishFrom.APPLICATION_MANAGER)
|
||||
|
||||
manager.stop_listen.assert_called_once_with(execution_terminal=True)
|
||||
|
||||
def test_listener_close_aborts_unfinished_execution(self):
|
||||
with (
|
||||
patch("core.app.apps.base_app_queue_manager.redis_client") as redis_client,
|
||||
|
||||
@@ -50,6 +50,7 @@ from core.app.entities.task_entities import (
|
||||
from core.base.tts.app_generator_tts_publisher import AudioTrunk
|
||||
from core.workflow.system_variables import build_system_variables, system_variables_to_mapping
|
||||
from graphon.enums import BuiltinNodeTypes, WorkflowExecutionStatus
|
||||
from graphon.model_runtime.entities.llm_entities import LLMUsage
|
||||
from graphon.runtime import GraphRuntimeState, VariablePool
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.enums import CreatorUserRole
|
||||
@@ -103,7 +104,7 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
variables=build_system_variables(workflow_execution_id="run-id"),
|
||||
),
|
||||
start_at=0.0,
|
||||
total_tokens=5,
|
||||
llm_usage=LLMUsage.empty_usage().model_copy(update={"total_tokens": 5}),
|
||||
node_run_steps=2,
|
||||
)
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from core.app.entities.app_invoke_entities import WorkflowAppGenerateEntity
|
||||
from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer
|
||||
from core.ops.ops_trace_manager import TraceTask, TraceTaskName
|
||||
from core.workflow.system_variables import SystemVariableKey, build_system_variables
|
||||
from graphon.entities import WorkflowNodeExecution
|
||||
from graphon.entities import WorkflowNodeExecution, WorkflowStartReason
|
||||
from graphon.entities.pause_reason import SchedulingPause
|
||||
from graphon.enums import (
|
||||
BuiltinNodeTypes,
|
||||
@@ -32,6 +32,7 @@ from graphon.graph_events import (
|
||||
NodeRunStartedEvent,
|
||||
NodeRunSucceededEvent,
|
||||
)
|
||||
from graphon.model_runtime.entities.llm_entities import LLMUsage
|
||||
from graphon.node_events import NodeRunResult
|
||||
from graphon.runtime import GraphRuntimeState, ReadOnlyGraphRuntimeStateWrapper, VariablePool
|
||||
|
||||
@@ -41,6 +42,7 @@ class _RepoRecorder:
|
||||
self.saved: list[object] = []
|
||||
self.synchronously_saved: list[object] = []
|
||||
self.saved_exec_data: list[object] = []
|
||||
self.loaded: list[object] = []
|
||||
|
||||
def save(self, entity):
|
||||
self.saved.append(entity)
|
||||
@@ -51,6 +53,9 @@ class _RepoRecorder:
|
||||
def save_execution_data(self, entity):
|
||||
self.saved_exec_data.append(entity)
|
||||
|
||||
def get_by_workflow_execution(self, _workflow_execution_id):
|
||||
return self.loaded
|
||||
|
||||
|
||||
def _naive_utc_now() -> datetime:
|
||||
return datetime.now(UTC).replace(tzinfo=None)
|
||||
@@ -169,12 +174,45 @@ class TestWorkflowPersistenceLayer:
|
||||
|
||||
assert exec_repo.saved
|
||||
|
||||
def test_resumption_restores_container_execution_before_terminal_event(self):
|
||||
layer, _, node_repo, _ = _make_layer()
|
||||
started_at = _naive_utc_now()
|
||||
execution = WorkflowNodeExecution(
|
||||
id="loop-exec",
|
||||
workflow_id="workflow-id",
|
||||
workflow_execution_id="run-id",
|
||||
index=4,
|
||||
node_id="loop",
|
||||
node_type=BuiltinNodeTypes.LOOP,
|
||||
title="Loop",
|
||||
status=WorkflowNodeExecutionStatus.RUNNING,
|
||||
created_at=started_at,
|
||||
)
|
||||
node_repo.loaded = [execution]
|
||||
|
||||
layer.on_event(GraphRunStartedEvent(reason=WorkflowStartReason.RESUMPTION))
|
||||
layer.on_event(
|
||||
NodeRunSucceededEvent(
|
||||
id=execution.id,
|
||||
node_id=execution.node_id,
|
||||
node_type=execution.node_type,
|
||||
start_at=started_at,
|
||||
node_run_result=NodeRunResult(status=WorkflowNodeExecutionStatus.SUCCEEDED),
|
||||
)
|
||||
)
|
||||
|
||||
assert execution.status == WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
assert layer._next_node_sequence() == 5
|
||||
|
||||
def test_handle_graph_run_succeeded_updates_execution(self):
|
||||
layer, exec_repo, _, runtime_state = _make_layer()
|
||||
layer._handle_graph_run_started()
|
||||
runtime_state.total_tokens = 3
|
||||
runtime_state.node_run_steps = 2
|
||||
runtime_state.outputs = {"out": "v"}
|
||||
usage = LLMUsage.empty_usage()
|
||||
usage.total_tokens = 3
|
||||
runtime_state.add_llm_usage(usage)
|
||||
for _ in range(2):
|
||||
runtime_state.increment_node_run_steps()
|
||||
runtime_state.set_output("out", "v")
|
||||
|
||||
layer._handle_graph_run_succeeded(GraphRunSucceededEvent(outputs={"ok": True}))
|
||||
|
||||
@@ -186,8 +224,11 @@ class TestWorkflowPersistenceLayer:
|
||||
def test_handle_graph_run_partial_succeeded_updates_execution(self):
|
||||
layer, exec_repo, _, runtime_state = _make_layer()
|
||||
layer._handle_graph_run_started()
|
||||
runtime_state.total_tokens = 5
|
||||
runtime_state.node_run_steps = 4
|
||||
usage = LLMUsage.empty_usage()
|
||||
usage.total_tokens = 5
|
||||
runtime_state.add_llm_usage(usage)
|
||||
for _ in range(4):
|
||||
runtime_state.increment_node_run_steps()
|
||||
runtime_state._graph_execution = SimpleNamespace(exceptions_count=2)
|
||||
|
||||
layer._handle_graph_run_partial_succeeded(
|
||||
@@ -293,8 +334,11 @@ class TestWorkflowPersistenceLayer:
|
||||
def test_handle_graph_run_paused_updates_outputs(self):
|
||||
layer, exec_repo, _, runtime_state = _make_layer()
|
||||
layer._handle_graph_run_started()
|
||||
runtime_state.total_tokens = 7
|
||||
runtime_state.node_run_steps = 5
|
||||
usage = LLMUsage.empty_usage()
|
||||
usage.total_tokens = 7
|
||||
runtime_state.add_llm_usage(usage)
|
||||
for _ in range(5):
|
||||
runtime_state.increment_node_run_steps()
|
||||
|
||||
layer._handle_graph_run_paused(GraphRunPausedEvent(outputs={"pause": True}))
|
||||
|
||||
|
||||
+54
@@ -262,6 +262,60 @@ class TestCeleryWorkflowNodeExecutionRepository:
|
||||
# Should return empty list since nothing in cache
|
||||
assert len(result) == 0
|
||||
|
||||
def test_get_by_workflow_execution_loads_persisted_executions_on_cache_miss(
|
||||
self, mock_session_factory, mock_account, sample_workflow_node_execution
|
||||
):
|
||||
repo = CeleryWorkflowNodeExecutionRepository(
|
||||
session_factory=mock_session_factory,
|
||||
tenant_id=RESOURCE_TENANT_ID,
|
||||
user=mock_account,
|
||||
app_id="test-app",
|
||||
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
|
||||
)
|
||||
repo._sql_repository = Mock()
|
||||
repo._sql_repository.get_by_workflow_execution.return_value = [sample_workflow_node_execution]
|
||||
|
||||
result = repo.get_by_workflow_execution(sample_workflow_node_execution.workflow_execution_id)
|
||||
|
||||
assert result == [sample_workflow_node_execution]
|
||||
assert repo._execution_cache[sample_workflow_node_execution.id] is sample_workflow_node_execution
|
||||
assert repo._workflow_execution_mapping[sample_workflow_node_execution.workflow_execution_id] == [
|
||||
sample_workflow_node_execution.id
|
||||
]
|
||||
|
||||
@patch("core.repositories.celery_workflow_node_execution_repository.save_workflow_node_execution_task")
|
||||
def test_get_by_workflow_execution_merges_database_and_newer_cache(
|
||||
self, mock_task, mock_session_factory, mock_account, sample_workflow_node_execution
|
||||
):
|
||||
repo = CeleryWorkflowNodeExecutionRepository(
|
||||
session_factory=mock_session_factory,
|
||||
tenant_id=RESOURCE_TENANT_ID,
|
||||
user=mock_account,
|
||||
app_id="test-app",
|
||||
triggered_from=WorkflowNodeExecutionTriggeredFrom.WORKFLOW_RUN,
|
||||
)
|
||||
persisted_current = sample_workflow_node_execution.model_copy(deep=True)
|
||||
historical = sample_workflow_node_execution.model_copy(
|
||||
update={
|
||||
"id": str(uuid4()),
|
||||
"node_execution_id": str(uuid4()),
|
||||
"index": 0,
|
||||
"node_id": "start",
|
||||
}
|
||||
)
|
||||
sample_workflow_node_execution.status = WorkflowNodeExecutionStatus.SUCCEEDED
|
||||
repo.save(sample_workflow_node_execution)
|
||||
repo._sql_repository = Mock()
|
||||
repo._sql_repository.get_by_workflow_execution.return_value = [persisted_current, historical]
|
||||
|
||||
result = repo.get_by_workflow_execution(
|
||||
sample_workflow_node_execution.workflow_execution_id,
|
||||
OrderConfig(order_by=["index"], order_direction="asc"),
|
||||
)
|
||||
|
||||
assert [execution.id for execution in result] == [historical.id, sample_workflow_node_execution.id]
|
||||
assert result[1] is sample_workflow_node_execution
|
||||
|
||||
@patch("core.repositories.celery_workflow_node_execution_repository.save_workflow_node_execution_task")
|
||||
def test_cache_operations(self, mock_task, mock_session_factory, mock_account, sample_workflow_node_execution):
|
||||
"""Test cache operations work correctly."""
|
||||
|
||||
@@ -1083,14 +1083,14 @@ def test_convert_tool_parameters_type_agent_and_workflow_branches():
|
||||
|
||||
variable_pool = Mock()
|
||||
variable_pool.get.return_value = SimpleNamespace(value="from-variable")
|
||||
variable_pool.convert_template.return_value = SimpleNamespace(text="from-template")
|
||||
|
||||
mixed = ToolManager._convert_tool_parameters_type(
|
||||
parameters=[text_param],
|
||||
variable_pool=variable_pool,
|
||||
tool_configurations={"text": {"type": "mixed", "value": "Hello {{name}}"}},
|
||||
typ="workflow",
|
||||
)
|
||||
with patch("core.tools.tool_manager.convert_template", return_value=SimpleNamespace(text="from-template")):
|
||||
mixed = ToolManager._convert_tool_parameters_type(
|
||||
parameters=[text_param],
|
||||
variable_pool=variable_pool,
|
||||
tool_configurations={"text": {"type": "mixed", "value": "Hello {{name}}"}},
|
||||
typ="workflow",
|
||||
)
|
||||
assert mixed == {"text": "from-template"}
|
||||
|
||||
variable = ToolManager._convert_tool_parameters_type(
|
||||
|
||||
@@ -28,6 +28,7 @@ from graphon.variables.segments import (
|
||||
StringSegment,
|
||||
get_segment_discriminator,
|
||||
)
|
||||
from graphon.variables.template_resolution import convert_template
|
||||
from graphon.variables.types import SegmentType
|
||||
from graphon.variables.utils import (
|
||||
dumps_with_segments,
|
||||
@@ -98,7 +99,7 @@ def test_segment_group_to_text():
|
||||
template = (
|
||||
"Hello, {{#sys.user_id#}}! Your query is {{#node_id.custom_query#}}. And your key is {{#env.secret_key#}}."
|
||||
)
|
||||
segments_group = variable_pool.convert_template(template)
|
||||
segments_group = convert_template(variable_pool, template)
|
||||
|
||||
assert segments_group.text == "Hello, fake-user-id! Your query is fake-user-query. And your key is fake-secret-key."
|
||||
assert segments_group.log == (
|
||||
@@ -112,7 +113,7 @@ def test_convert_constant_to_segment_group():
|
||||
system_variables=build_system_variables(user_id="1", app_id="1", workflow_id="1"),
|
||||
)
|
||||
template = "Hello, world!"
|
||||
segments_group = variable_pool.convert_template(template)
|
||||
segments_group = convert_template(variable_pool, template)
|
||||
assert segments_group.text == "Hello, world!"
|
||||
assert segments_group.log == "Hello, world!"
|
||||
|
||||
@@ -120,7 +121,7 @@ def test_convert_constant_to_segment_group():
|
||||
def test_convert_variable_to_segment_group():
|
||||
variable_pool = _build_variable_pool(system_variables=build_system_variables(user_id="fake-user-id"))
|
||||
template = "{{#sys.user_id#}}"
|
||||
segments_group = variable_pool.convert_template(template)
|
||||
segments_group = convert_template(variable_pool, template)
|
||||
assert segments_group.text == "fake-user-id"
|
||||
assert segments_group.log == "fake-user-id"
|
||||
assert isinstance(segments_group.value[0], StringVariable)
|
||||
|
||||
@@ -5,7 +5,7 @@ The factory follows the same config adaptation path as production
|
||||
implementations before instantiation.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, override
|
||||
|
||||
from core.workflow.human_input_adapter import adapt_node_config_for_graph
|
||||
from core.workflow.node_factory import DifyNodeFactory
|
||||
@@ -76,6 +76,14 @@ class MockNodeFactory(DifyNodeFactory):
|
||||
BuiltinNodeTypes.CODE: MockCodeNode,
|
||||
}
|
||||
|
||||
@override
|
||||
def with_runtime_state(self, graph_runtime_state: "GraphRuntimeState") -> "MockNodeFactory":
|
||||
return MockNodeFactory(
|
||||
graph_init_params=self.graph_init_params,
|
||||
graph_runtime_state=graph_runtime_state,
|
||||
mock_config=self.mock_config,
|
||||
)
|
||||
|
||||
def create_node(self, node_config: dict[str, Any] | NodeConfigDict) -> Node:
|
||||
"""
|
||||
Create a node instance, using mock implementations for third-party service nodes.
|
||||
|
||||
@@ -615,69 +615,6 @@ class MockIterationNode(MockNodeMixin, IterationNode):
|
||||
"""Return the version of this mock node."""
|
||||
return "1"
|
||||
|
||||
def _create_graph_engine(self, index: int, item: Any):
|
||||
"""Create a graph engine with MockNodeFactory instead of DifyNodeFactory."""
|
||||
# Import dependencies
|
||||
from graphon.entities import GraphInitParams
|
||||
from graphon.graph import Graph
|
||||
from graphon.graph_engine import GraphEngine, GraphEngineConfig
|
||||
from graphon.graph_engine.command_channels import InMemoryChannel
|
||||
from graphon.runtime import GraphRuntimeState
|
||||
|
||||
# Import our MockNodeFactory instead of DifyNodeFactory
|
||||
from .test_mock_factory import MockNodeFactory
|
||||
|
||||
# Create GraphInitParams from node attributes
|
||||
graph_init_params = GraphInitParams(
|
||||
workflow_id=self.workflow_id,
|
||||
graph_config=self.graph_config,
|
||||
run_context=self.run_context,
|
||||
call_depth=self.workflow_call_depth,
|
||||
)
|
||||
|
||||
# Create a deep copy of the variable pool for each iteration
|
||||
variable_pool_copy = self.graph_runtime_state.variable_pool.model_copy(deep=True)
|
||||
|
||||
# append iteration variable (item, index) to variable pool
|
||||
variable_pool_copy.add([self._node_id, "index"], index)
|
||||
variable_pool_copy.add([self._node_id, "item"], item)
|
||||
|
||||
# Create a new GraphRuntimeState for this iteration
|
||||
graph_runtime_state_copy = GraphRuntimeState(
|
||||
variable_pool=variable_pool_copy,
|
||||
start_at=self.graph_runtime_state.start_at,
|
||||
total_tokens=0,
|
||||
node_run_steps=0,
|
||||
)
|
||||
|
||||
# Create a MockNodeFactory with the same mock_config
|
||||
node_factory = MockNodeFactory(
|
||||
graph_init_params=graph_init_params,
|
||||
graph_runtime_state=graph_runtime_state_copy,
|
||||
mock_config=self.mock_config, # Pass the mock configuration
|
||||
)
|
||||
|
||||
# Initialize the iteration graph with the mock node factory
|
||||
iteration_graph = Graph.init(
|
||||
graph_config=self.graph_config, node_factory=node_factory, root_node_id=self._node_data.start_node_id
|
||||
)
|
||||
|
||||
if not iteration_graph:
|
||||
from graphon.nodes.iteration.exc import IterationGraphNotFoundError
|
||||
|
||||
raise IterationGraphNotFoundError("iteration graph not found")
|
||||
|
||||
# Create a new GraphEngine for this iteration
|
||||
graph_engine = GraphEngine(
|
||||
workflow_id=self.workflow_id,
|
||||
graph=iteration_graph,
|
||||
graph_runtime_state=graph_runtime_state_copy,
|
||||
command_channel=InMemoryChannel(), # Use InMemoryChannel for sub-graphs
|
||||
config=GraphEngineConfig(),
|
||||
)
|
||||
|
||||
return graph_engine
|
||||
|
||||
|
||||
class MockLoopNode(MockNodeMixin, LoopNode):
|
||||
"""Mock implementation of LoopNode that preserves mock configuration."""
|
||||
@@ -687,56 +624,6 @@ class MockLoopNode(MockNodeMixin, LoopNode):
|
||||
"""Return the version of this mock node."""
|
||||
return "1"
|
||||
|
||||
def _create_graph_engine(self, start_at, root_node_id: str):
|
||||
"""Create a graph engine with MockNodeFactory instead of DifyNodeFactory."""
|
||||
# Import dependencies
|
||||
from graphon.entities import GraphInitParams
|
||||
from graphon.graph import Graph
|
||||
from graphon.graph_engine import GraphEngine, GraphEngineConfig
|
||||
from graphon.graph_engine.command_channels import InMemoryChannel
|
||||
from graphon.runtime import GraphRuntimeState
|
||||
|
||||
# Import our MockNodeFactory instead of DifyNodeFactory
|
||||
from .test_mock_factory import MockNodeFactory
|
||||
|
||||
# Create GraphInitParams from node attributes
|
||||
graph_init_params = GraphInitParams(
|
||||
workflow_id=self.workflow_id,
|
||||
graph_config=self.graph_config,
|
||||
run_context=self.run_context,
|
||||
call_depth=self.workflow_call_depth,
|
||||
)
|
||||
|
||||
# Create a new GraphRuntimeState for this iteration
|
||||
graph_runtime_state_copy = GraphRuntimeState(
|
||||
variable_pool=self.graph_runtime_state.variable_pool,
|
||||
start_at=start_at.timestamp(),
|
||||
)
|
||||
|
||||
# Create a MockNodeFactory with the same mock_config
|
||||
node_factory = MockNodeFactory(
|
||||
graph_init_params=graph_init_params,
|
||||
graph_runtime_state=graph_runtime_state_copy,
|
||||
mock_config=self.mock_config, # Pass the mock configuration
|
||||
)
|
||||
|
||||
# Initialize the loop graph with the mock node factory
|
||||
loop_graph = Graph.init(graph_config=self.graph_config, node_factory=node_factory, root_node_id=root_node_id)
|
||||
|
||||
if not loop_graph:
|
||||
raise ValueError("loop graph not found")
|
||||
|
||||
# Create a new GraphEngine for this iteration
|
||||
graph_engine = GraphEngine(
|
||||
workflow_id=self.workflow_id,
|
||||
graph=loop_graph,
|
||||
graph_runtime_state=graph_runtime_state_copy,
|
||||
command_channel=InMemoryChannel(), # Use InMemoryChannel for sub-graphs
|
||||
config=GraphEngineConfig(),
|
||||
)
|
||||
|
||||
return graph_engine
|
||||
|
||||
|
||||
class MockTemplateTransformNode(MockNodeMixin, TemplateTransformNode):
|
||||
"""Mock implementation of TemplateTransformNode for testing."""
|
||||
|
||||
@@ -51,53 +51,6 @@ from .test_mock_factory import MockNodeFactory
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _TableTestChildEngineBuilder:
|
||||
def __init__(self, *, use_mock_factory: bool, mock_config: MockConfig | None) -> None:
|
||||
self._use_mock_factory = use_mock_factory
|
||||
self._mock_config = mock_config
|
||||
|
||||
def build_child_engine(
|
||||
self,
|
||||
*,
|
||||
workflow_id: str,
|
||||
graph_init_params: GraphInitParams,
|
||||
parent_graph_runtime_state: GraphRuntimeState,
|
||||
root_node_id: str,
|
||||
variable_pool: VariablePool | None = None,
|
||||
) -> GraphEngine:
|
||||
child_graph_runtime_state = GraphRuntimeState(
|
||||
variable_pool=variable_pool if variable_pool is not None else parent_graph_runtime_state.variable_pool,
|
||||
start_at=time.perf_counter(),
|
||||
execution_context=parent_graph_runtime_state.execution_context,
|
||||
)
|
||||
if self._use_mock_factory:
|
||||
node_factory = MockNodeFactory(
|
||||
graph_init_params=graph_init_params,
|
||||
graph_runtime_state=child_graph_runtime_state,
|
||||
mock_config=self._mock_config,
|
||||
)
|
||||
else:
|
||||
node_factory = DifyNodeFactory(
|
||||
graph_init_params=graph_init_params,
|
||||
graph_runtime_state=child_graph_runtime_state,
|
||||
)
|
||||
|
||||
graph_config = graph_init_params.graph_config
|
||||
child_graph = Graph.init(graph_config=graph_config, node_factory=node_factory, root_node_id=root_node_id)
|
||||
if not child_graph:
|
||||
raise ValueError("child graph not found")
|
||||
|
||||
child_engine = GraphEngine(
|
||||
workflow_id=workflow_id,
|
||||
graph=child_graph,
|
||||
graph_runtime_state=child_graph_runtime_state,
|
||||
command_channel=InMemoryChannel(),
|
||||
config=GraphEngineConfig(),
|
||||
child_engine_builder=self,
|
||||
)
|
||||
return child_engine
|
||||
|
||||
|
||||
@dataclass
|
||||
class WorkflowTestCase:
|
||||
"""Represents a single test case for table-driven testing."""
|
||||
@@ -379,10 +332,6 @@ class TableTestRunner:
|
||||
scale_up_threshold=self.graph_engine_scale_up_threshold,
|
||||
scale_down_idle_time=self.graph_engine_scale_down_idle_time,
|
||||
),
|
||||
child_engine_builder=_TableTestChildEngineBuilder(
|
||||
use_mock_factory=test_case.use_auto_mock,
|
||||
mock_config=test_case.mock_config,
|
||||
),
|
||||
)
|
||||
|
||||
# Execute and collect events
|
||||
|
||||
@@ -3,7 +3,7 @@ from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import UUID
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from agenton.compositor import CompositorSessionSnapshot
|
||||
from dify_agent.layers.ask_human import AskHumanToolResult
|
||||
@@ -347,7 +347,7 @@ def _node(
|
||||
}
|
||||
)
|
||||
|
||||
return DifyAgentNode(
|
||||
node = DifyAgentNode(
|
||||
node_id="agent-node",
|
||||
data=DifyAgentNodeData.model_validate({"type": BuiltinNodeTypes.AGENT, "version": "2"}),
|
||||
graph_init_params=graph_init_params,
|
||||
@@ -355,7 +355,7 @@ def _node(
|
||||
GraphRuntimeState,
|
||||
SimpleNamespace(
|
||||
variable_pool=FakeVariablePool(),
|
||||
graph_execution=SimpleNamespace(node_executions={}),
|
||||
graph_execution=SimpleNamespace(aborted=False),
|
||||
),
|
||||
),
|
||||
binding_resolver=binding_resolver,
|
||||
@@ -368,6 +368,8 @@ def _node(
|
||||
failure_orchestrator=OutputFailureOrchestrator(),
|
||||
session_store=cast(WorkflowAgentWorkspaceStore, session_store or FakeSessionStore()),
|
||||
)
|
||||
node.bind_execution_id(str(uuid4()))
|
||||
return node
|
||||
|
||||
|
||||
def test_extract_variable_selector_to_variable_mapping_uses_frontend_agent_task_markers():
|
||||
@@ -465,7 +467,7 @@ def test_agent_node_passes_execution_id_to_session_store_and_runtime_request_bui
|
||||
store = FakeSessionStore()
|
||||
request_builder = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider())
|
||||
node = _node(session_store=store, runtime_request_builder=request_builder)
|
||||
execution_id = node.ensure_execution_id()
|
||||
execution_id = node.execution_id
|
||||
|
||||
with patch.object(request_builder, "build", wraps=request_builder.build) as build:
|
||||
list(node._run())
|
||||
|
||||
+3
-1
@@ -73,13 +73,15 @@ def _create_human_input_node(
|
||||
node_data=node_data,
|
||||
file_reference_factory=_TestFileReferenceFactory(),
|
||||
)
|
||||
return HumanInputNode(
|
||||
node = HumanInputNode(
|
||||
node_id=config["id"],
|
||||
data=node_data,
|
||||
graph_init_params=graph_init_params,
|
||||
graph_runtime_state=graph_runtime_state,
|
||||
hitl_callback=callback,
|
||||
)
|
||||
node.bind_execution_id("00000000-0000-4000-8000-000000000001")
|
||||
return node
|
||||
|
||||
|
||||
def _build_node(
|
||||
|
||||
-95
@@ -1,95 +0,0 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from core.workflow.system_variables import default_system_variables
|
||||
from graphon.entities import GraphInitParams
|
||||
from graphon.nodes.iteration.entities import IterationNodeData
|
||||
from graphon.nodes.iteration.exc import IterationGraphNotFoundError
|
||||
from graphon.nodes.iteration.iteration_node import IterationNode
|
||||
from graphon.runtime import (
|
||||
ChildEngineBuilderNotConfiguredError,
|
||||
ChildGraphNotFoundError,
|
||||
GraphRuntimeState,
|
||||
VariablePool,
|
||||
)
|
||||
from tests.workflow_test_utils import build_test_graph_init_params
|
||||
|
||||
|
||||
class _MissingGraphBuilder:
|
||||
def build_child_engine(
|
||||
self,
|
||||
*,
|
||||
workflow_id: str,
|
||||
graph_init_params: GraphInitParams,
|
||||
parent_graph_runtime_state: GraphRuntimeState,
|
||||
root_node_id: str,
|
||||
variable_pool: VariablePool | None = None,
|
||||
) -> object:
|
||||
raise ChildGraphNotFoundError(f"child graph root node '{root_node_id}' not found")
|
||||
|
||||
|
||||
def _build_runtime_state() -> GraphRuntimeState:
|
||||
return GraphRuntimeState(
|
||||
variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables(), user_inputs={}),
|
||||
start_at=0.0,
|
||||
)
|
||||
|
||||
|
||||
def _build_iteration_node(
|
||||
*,
|
||||
graph_config: Mapping[str, Any],
|
||||
runtime_state: GraphRuntimeState,
|
||||
start_node_id: str,
|
||||
) -> IterationNode:
|
||||
init_params = build_test_graph_init_params(graph_config=graph_config)
|
||||
return IterationNode(
|
||||
node_id="iteration-node",
|
||||
data=IterationNodeData(
|
||||
type="iteration",
|
||||
title="Iteration",
|
||||
iterator_selector=["start", "items"],
|
||||
output_selector=["iteration-node", "output"],
|
||||
start_node_id=start_node_id,
|
||||
),
|
||||
graph_init_params=init_params,
|
||||
graph_runtime_state=runtime_state,
|
||||
)
|
||||
|
||||
|
||||
def test_graph_runtime_state_raises_specific_error_when_child_builder_is_missing():
|
||||
runtime_state = _build_runtime_state()
|
||||
graph_init_params = build_test_graph_init_params()
|
||||
|
||||
with pytest.raises(ChildEngineBuilderNotConfiguredError):
|
||||
runtime_state.create_child_engine(
|
||||
workflow_id="workflow",
|
||||
graph_init_params=graph_init_params,
|
||||
root_node_id="root",
|
||||
)
|
||||
|
||||
|
||||
def test_iteration_node_only_translates_child_graph_not_found_error():
|
||||
runtime_state = _build_runtime_state()
|
||||
runtime_state.bind_child_engine_builder(_MissingGraphBuilder())
|
||||
node = _build_iteration_node(
|
||||
graph_config={"nodes": [{"id": "present-node"}], "edges": []},
|
||||
runtime_state=runtime_state,
|
||||
start_node_id="missing-node",
|
||||
)
|
||||
|
||||
with pytest.raises(IterationGraphNotFoundError):
|
||||
node._create_graph_engine(index=0, item="item")
|
||||
|
||||
|
||||
def test_iteration_node_propagates_non_graph_not_found_errors():
|
||||
runtime_state = _build_runtime_state()
|
||||
node = _build_iteration_node(
|
||||
graph_config={"nodes": [{"id": "start-node"}], "edges": []},
|
||||
runtime_state=runtime_state,
|
||||
start_node_id="start-node",
|
||||
)
|
||||
|
||||
with pytest.raises(ChildEngineBuilderNotConfiguredError):
|
||||
node._create_graph_engine(index=0, item="item")
|
||||
@@ -1,4 +1,3 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -36,7 +35,6 @@ class TestListOperatorNode:
|
||||
"""Create mock GraphRuntimeState."""
|
||||
mock_state = MagicMock(spec=GraphRuntimeState)
|
||||
mock_variable_pool = MagicMock()
|
||||
mock_variable_pool.convert_template.side_effect = lambda value: SimpleNamespace(text=value)
|
||||
mock_state.variable_pool = mock_variable_pool
|
||||
return mock_state
|
||||
|
||||
|
||||
@@ -239,7 +239,6 @@ def test_image_link_messages_use_tool_file_id_metadata(tool_node: ToolNode):
|
||||
def test_tool_node_passes_node_execution_id_when_runtime_accepts_it(tool_node: ToolNode):
|
||||
runtime_handle = ToolRuntimeHandle(raw=object())
|
||||
tool_node._runtime.get_runtime = MagicMock(return_value=runtime_handle)
|
||||
tool_node.ensure_execution_id = MagicMock(return_value="node-execution-id")
|
||||
|
||||
result = tool_node._get_tool_runtime(
|
||||
variable_pool=tool_node.graph_runtime_state.variable_pool,
|
||||
|
||||
@@ -18,12 +18,12 @@ from core.workflow.human_input_adapter import (
|
||||
)
|
||||
from graphon.enums import BuiltinNodeTypes
|
||||
from graphon.nodes.base.variable_template_parser import VariableTemplateParser
|
||||
from graphon.runtime import VariablePool
|
||||
|
||||
|
||||
def test_email_delivery_config_helpers_render_and_sanitize_text() -> None:
|
||||
variable_pool = SimpleNamespace(
|
||||
convert_template=lambda body: SimpleNamespace(text=body.replace("{{#node.value#}}", "42"))
|
||||
)
|
||||
variable_pool = VariablePool()
|
||||
variable_pool.add(["node", "value"], "42")
|
||||
|
||||
rendered = EmailDeliveryConfig.render_body_template(
|
||||
body="Open {{#url#}} and use {{#node.value#}}",
|
||||
|
||||
@@ -59,6 +59,27 @@ def test_dify_hitl_callback_creates_pause_requested_for_new_form() -> None:
|
||||
assert params.node_id == "node-1"
|
||||
|
||||
|
||||
def test_dify_hitl_callback_scopes_form_to_node_execution() -> None:
|
||||
repository = MagicMock(spec=HumanInputFormRepository)
|
||||
repository.get_form.return_value = None
|
||||
repository.create_form.return_value = SimpleNamespace(id="execution-1")
|
||||
callback = DifyHITLCallback(
|
||||
form_repository=repository,
|
||||
node_data=HumanInputNodeData(
|
||||
title="Approval",
|
||||
form_content="Please approve",
|
||||
user_actions=[UserActionConfig(id="approve", title="Approve")],
|
||||
),
|
||||
execution_id_getter=lambda: "execution-1",
|
||||
)
|
||||
|
||||
callback(_ctx("run-1", "node-1"))
|
||||
|
||||
repository.get_form.assert_called_once_with("node-1", form_id="execution-1")
|
||||
params: FormCreateParams = repository.create_form.call_args.args[0]
|
||||
assert params.form_id == "execution-1"
|
||||
|
||||
|
||||
def test_dify_hitl_callback_returns_completed_for_submitted_form() -> None:
|
||||
repository = MagicMock(spec=HumanInputFormRepository)
|
||||
repository.get_form.return_value = SimpleNamespace(
|
||||
|
||||
@@ -324,6 +324,19 @@ class TestDifyNodeFactoryInit:
|
||||
graph_runtime_state=sentinel.graph_runtime_state,
|
||||
)
|
||||
|
||||
def test_with_runtime_state_rebinds_factory(self):
|
||||
factory = object.__new__(node_factory.DifyNodeFactory)
|
||||
factory.graph_init_params = sentinel.graph_init_params
|
||||
|
||||
with patch.object(node_factory, "DifyNodeFactory", return_value=sentinel.factory) as factory_cls:
|
||||
rebound = factory.with_runtime_state(sentinel.graph_runtime_state)
|
||||
|
||||
assert rebound is sentinel.factory
|
||||
factory_cls.assert_called_once_with(
|
||||
graph_init_params=sentinel.graph_init_params,
|
||||
graph_runtime_state=sentinel.graph_runtime_state,
|
||||
)
|
||||
|
||||
def test_init_builds_default_dependencies(self):
|
||||
graph_init_params = SimpleNamespace(run_context={"context": "value"})
|
||||
graph_runtime_state = sentinel.graph_runtime_state
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
from collections import UserString
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch, sentinel
|
||||
|
||||
@@ -10,238 +9,20 @@ from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
|
||||
from core.workflow import workflow_entry
|
||||
from core.workflow.system_variables import default_system_variables
|
||||
from graphon.entities.base_node_data import BaseNodeData
|
||||
from graphon.enums import NodeType, WorkflowNodeExecutionStatus
|
||||
from graphon.enums import NodeType
|
||||
from graphon.errors import WorkflowNodeRunFailedError
|
||||
from graphon.file import File, FileTransferMethod, FileType
|
||||
from graphon.filters import ResponseStreamFilter
|
||||
from graphon.graph import Graph
|
||||
from graphon.graph_events import GraphRunFailedEvent
|
||||
from graphon.model_runtime.entities.llm_entities import LLMMode, LLMUsage
|
||||
from graphon.node_events import NodeRunResult
|
||||
from graphon.nodes import BuiltinNodeTypes
|
||||
from graphon.nodes.base.node import Node
|
||||
from graphon.nodes.llm.entities import ContextConfig, LLMNodeData, ModelConfig
|
||||
from graphon.nodes.question_classifier.entities import QuestionClassifierNodeData
|
||||
from graphon.runtime import ChildGraphNotFoundError, VariablePool
|
||||
from graphon.runtime import VariablePool
|
||||
from graphon.variables.variables import StringVariable
|
||||
from tests.workflow_test_utils import build_test_graph_init_params, build_test_variable_pool
|
||||
|
||||
|
||||
def _build_typed_node_config(node_type: NodeType):
|
||||
return {"id": "node-id", "data": BaseNodeData(type=node_type)}
|
||||
|
||||
|
||||
def _build_model_config(*, provider: str = "openai", model_name: str = "gpt-4o") -> ModelConfig:
|
||||
return ModelConfig(provider=provider, name=model_name, mode=LLMMode.CHAT)
|
||||
|
||||
|
||||
def _build_llm_node_data(*, provider: str = "openai", model_name: str = "gpt-4o") -> LLMNodeData:
|
||||
return LLMNodeData(
|
||||
type=BuiltinNodeTypes.LLM,
|
||||
title="Child Model",
|
||||
model=_build_model_config(provider=provider, model_name=model_name),
|
||||
prompt_template=[],
|
||||
context=ContextConfig(enabled=False),
|
||||
)
|
||||
|
||||
|
||||
def _build_question_classifier_node_data(
|
||||
*, provider: str = "openai", model_name: str = "gpt-4o"
|
||||
) -> QuestionClassifierNodeData:
|
||||
return QuestionClassifierNodeData(
|
||||
type=BuiltinNodeTypes.QUESTION_CLASSIFIER,
|
||||
title="Child Model",
|
||||
query_variable_selector=["sys", "query"],
|
||||
model=_build_model_config(provider=provider, model_name=model_name),
|
||||
classes=[],
|
||||
)
|
||||
|
||||
|
||||
class _FakeModelNodeMixin:
|
||||
@classmethod
|
||||
def version(cls) -> str:
|
||||
return "1"
|
||||
|
||||
def post_init(self) -> None:
|
||||
self.model_instance = SimpleNamespace(provider="stale-provider", model_name="stale-model")
|
||||
self.usage_snapshot = LLMUsage.empty_usage()
|
||||
self.usage_snapshot.total_tokens = 1
|
||||
|
||||
def _run(self) -> NodeRunResult:
|
||||
return NodeRunResult(
|
||||
status=WorkflowNodeExecutionStatus.SUCCEEDED,
|
||||
inputs={
|
||||
"model_provider": self.node_data.model.provider,
|
||||
"model_name": self.node_data.model.name,
|
||||
},
|
||||
llm_usage=self.usage_snapshot,
|
||||
)
|
||||
|
||||
|
||||
class _FakeLLMNode(_FakeModelNodeMixin, Node[LLMNodeData]):
|
||||
node_type = BuiltinNodeTypes.LLM
|
||||
|
||||
|
||||
class _FakeQuestionClassifierNode(_FakeModelNodeMixin, Node[QuestionClassifierNodeData]):
|
||||
node_type = BuiltinNodeTypes.QUESTION_CLASSIFIER
|
||||
|
||||
|
||||
class TestWorkflowChildEngineBuilder:
|
||||
@pytest.mark.parametrize(
|
||||
("graph_config", "node_id", "expected"),
|
||||
[
|
||||
({"nodes": [{"id": "root"}]}, "root", True),
|
||||
({"nodes": [{"id": "root"}]}, "other", False),
|
||||
({"nodes": "invalid"}, "root", None),
|
||||
({"nodes": ["invalid"]}, "root", None),
|
||||
],
|
||||
)
|
||||
def test_has_node_id(self, graph_config, node_id, expected):
|
||||
result = workflow_entry._WorkflowChildEngineBuilder._has_node_id(graph_config, node_id)
|
||||
|
||||
assert result is expected
|
||||
|
||||
def test_build_child_engine_raises_when_root_node_is_missing(self):
|
||||
builder = workflow_entry._WorkflowChildEngineBuilder(tenant_id="tenant-id")
|
||||
graph_init_params = SimpleNamespace(graph_config={"nodes": []})
|
||||
parent_graph_runtime_state = SimpleNamespace(
|
||||
execution_context=sentinel.execution_context,
|
||||
variable_pool=sentinel.variable_pool,
|
||||
)
|
||||
|
||||
with patch.object(workflow_entry, "DifyNodeFactory", return_value=sentinel.factory):
|
||||
with pytest.raises(ChildGraphNotFoundError, match="child graph root node 'missing' not found"):
|
||||
builder.build_child_engine(
|
||||
workflow_id="workflow-id",
|
||||
graph_init_params=graph_init_params,
|
||||
parent_graph_runtime_state=parent_graph_runtime_state,
|
||||
root_node_id="missing",
|
||||
)
|
||||
|
||||
def test_build_child_engine_constructs_graph_engine_with_quota_layer_only(self):
|
||||
builder = workflow_entry._WorkflowChildEngineBuilder(tenant_id="tenant-id")
|
||||
graph_init_params = SimpleNamespace(graph_config={"nodes": [{"id": "root"}]})
|
||||
parent_graph_runtime_state = SimpleNamespace(
|
||||
execution_context=sentinel.execution_context,
|
||||
variable_pool=sentinel.parent_variable_pool,
|
||||
)
|
||||
child_graph = sentinel.child_graph
|
||||
child_graph_runtime_state = sentinel.child_graph_runtime_state
|
||||
child_engine = MagicMock()
|
||||
|
||||
with (
|
||||
patch.object(workflow_entry.time, "perf_counter", return_value=123.0),
|
||||
patch.object(
|
||||
workflow_entry,
|
||||
"GraphRuntimeState",
|
||||
return_value=child_graph_runtime_state,
|
||||
) as graph_runtime_state_cls,
|
||||
patch.object(workflow_entry, "DifyNodeFactory", return_value=sentinel.factory) as dify_node_factory,
|
||||
patch.object(workflow_entry.Graph, "init", return_value=child_graph) as graph_init,
|
||||
patch.object(workflow_entry, "GraphEngine", return_value=child_engine) as graph_engine_cls,
|
||||
patch.object(workflow_entry, "GraphEngineConfig", return_value=sentinel.graph_engine_config),
|
||||
patch.object(workflow_entry, "InMemoryChannel", return_value=sentinel.command_channel),
|
||||
patch.object(workflow_entry, "LLMQuotaLayer", return_value=sentinel.llm_quota_layer) as llm_quota_layer_cls,
|
||||
):
|
||||
result = builder.build_child_engine(
|
||||
workflow_id="workflow-id",
|
||||
graph_init_params=graph_init_params,
|
||||
parent_graph_runtime_state=parent_graph_runtime_state,
|
||||
root_node_id="root",
|
||||
variable_pool=sentinel.child_variable_pool,
|
||||
)
|
||||
|
||||
assert result is child_engine
|
||||
graph_runtime_state_cls.assert_called_once_with(
|
||||
variable_pool=sentinel.child_variable_pool,
|
||||
start_at=123.0,
|
||||
execution_context=sentinel.execution_context,
|
||||
)
|
||||
dify_node_factory.assert_called_once_with(
|
||||
graph_init_params=graph_init_params,
|
||||
graph_runtime_state=child_graph_runtime_state,
|
||||
)
|
||||
graph_init.assert_called_once_with(
|
||||
graph_config={"nodes": [{"id": "root"}]},
|
||||
node_factory=sentinel.factory,
|
||||
root_node_id="root",
|
||||
)
|
||||
graph_engine_cls.assert_called_once_with(
|
||||
workflow_id="workflow-id",
|
||||
graph=child_graph,
|
||||
graph_runtime_state=child_graph_runtime_state,
|
||||
command_channel=sentinel.command_channel,
|
||||
config=sentinel.graph_engine_config,
|
||||
child_engine_builder=builder,
|
||||
)
|
||||
llm_quota_layer_cls.assert_called_once_with(tenant_id="tenant-id")
|
||||
assert child_engine.layer.call_args_list == [((sentinel.llm_quota_layer,), {})]
|
||||
|
||||
@pytest.mark.parametrize("node_cls", [_FakeLLMNode, _FakeQuestionClassifierNode])
|
||||
def test_build_child_engine_runs_llm_quota_layer_for_child_model_nodes(self, node_cls):
|
||||
builder = workflow_entry._WorkflowChildEngineBuilder(tenant_id="tenant-id")
|
||||
graph_init_params = build_test_graph_init_params(
|
||||
graph_config={"nodes": [{"id": "root"}], "edges": []},
|
||||
)
|
||||
parent_graph_runtime_state = SimpleNamespace(
|
||||
execution_context=nullcontext(None),
|
||||
variable_pool=build_test_variable_pool(),
|
||||
)
|
||||
created_node: dict[str, _FakeLLMNode | _FakeQuestionClassifierNode] = {}
|
||||
|
||||
def build_graph(*, graph_config, node_factory, root_node_id):
|
||||
_ = graph_config
|
||||
node_data = _build_llm_node_data() if node_cls is _FakeLLMNode else _build_question_classifier_node_data()
|
||||
node = node_cls(
|
||||
node_id=root_node_id,
|
||||
data=node_data,
|
||||
graph_init_params=node_factory.graph_init_params,
|
||||
graph_runtime_state=node_factory.graph_runtime_state,
|
||||
)
|
||||
created_node["node"] = node
|
||||
return Graph(
|
||||
nodes={root_node_id: node},
|
||||
edges={},
|
||||
in_edges={},
|
||||
out_edges={},
|
||||
root_node=node,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
workflow_entry,
|
||||
"DifyNodeFactory",
|
||||
side_effect=lambda graph_init_params, graph_runtime_state: SimpleNamespace(
|
||||
graph_init_params=graph_init_params,
|
||||
graph_runtime_state=graph_runtime_state,
|
||||
),
|
||||
),
|
||||
patch.object(workflow_entry.Graph, "init", side_effect=build_graph),
|
||||
patch("core.app.workflow.layers.llm_quota.ensure_llm_quota_available_for_model") as ensure_quota,
|
||||
patch("core.app.workflow.layers.llm_quota.deduct_llm_quota_for_model") as deduct_quota,
|
||||
):
|
||||
child_engine = builder.build_child_engine(
|
||||
workflow_id="workflow-id",
|
||||
graph_init_params=graph_init_params,
|
||||
parent_graph_runtime_state=parent_graph_runtime_state,
|
||||
root_node_id="root",
|
||||
)
|
||||
list(child_engine.run())
|
||||
|
||||
node = created_node["node"]
|
||||
ensure_quota.assert_called_once_with(
|
||||
tenant_id="tenant-id",
|
||||
provider=node.node_data.model.provider,
|
||||
model=node.node_data.model.name,
|
||||
)
|
||||
deduct_quota.assert_called_once_with(
|
||||
tenant_id="tenant-id",
|
||||
provider=node.node_data.model.provider,
|
||||
model=node.node_data.model.name,
|
||||
usage=node.usage_snapshot,
|
||||
)
|
||||
|
||||
|
||||
def _build_minimal_workflow_entry(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
*,
|
||||
@@ -249,7 +30,7 @@ def _build_minimal_workflow_entry(
|
||||
) -> workflow_entry.WorkflowEntry:
|
||||
"""Construct a minimal WorkflowEntry with GraphEngine construction mocked out."""
|
||||
graph_engine = MagicMock()
|
||||
graph_runtime_state = SimpleNamespace(execution_context=None)
|
||||
graph_runtime_state = SimpleNamespace(_execution_context=None)
|
||||
|
||||
monkeypatch.setattr(workflow_entry, "capture_current_context", lambda: sentinel.execution_context)
|
||||
monkeypatch.setattr(workflow_entry, "GraphEngine", MagicMock(return_value=graph_engine))
|
||||
@@ -294,7 +75,7 @@ class TestWorkflowEntryInit:
|
||||
|
||||
def test_applies_debug_and_observability_layers(self):
|
||||
graph_engine = MagicMock()
|
||||
graph_runtime_state = SimpleNamespace(execution_context=None)
|
||||
graph_runtime_state = SimpleNamespace(_execution_context=None)
|
||||
debug_layer = sentinel.debug_layer
|
||||
execution_limits_layer = sentinel.execution_limits_layer
|
||||
llm_quota_layer = sentinel.llm_quota_layer
|
||||
@@ -339,9 +120,8 @@ class TestWorkflowEntryInit:
|
||||
graph_runtime_state=graph_runtime_state,
|
||||
command_channel=sentinel.command_channel,
|
||||
config=sentinel.graph_engine_config,
|
||||
child_engine_builder=entry._child_engine_builder,
|
||||
)
|
||||
assert graph_runtime_state.execution_context is sentinel.execution_context
|
||||
assert graph_runtime_state._execution_context is sentinel.execution_context
|
||||
debug_logging_layer.assert_called_once_with(
|
||||
level="DEBUG",
|
||||
include_inputs=True,
|
||||
@@ -443,6 +223,21 @@ class TestWorkflowEntryRun:
|
||||
|
||||
|
||||
class TestWorkflowEntrySingleStepRun:
|
||||
@pytest.mark.parametrize("node_type", [BuiltinNodeTypes.LOOP, BuiltinNodeTypes.ITERATION])
|
||||
def test_rejects_container_nodes(self, node_type):
|
||||
workflow = SimpleNamespace(
|
||||
get_node_config_by_id=lambda _node_id: _build_typed_node_config(node_type),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="engine-backed debug endpoints"):
|
||||
workflow_entry.WorkflowEntry.single_step_run(
|
||||
workflow=workflow,
|
||||
node_id="node-id",
|
||||
user_id="user-id",
|
||||
user_inputs={},
|
||||
variable_pool=sentinel.variable_pool,
|
||||
)
|
||||
|
||||
def test_preloads_constructor_variables_before_creating_memory_node(self):
|
||||
class FakeLLMNode:
|
||||
id = "node-id"
|
||||
@@ -958,7 +753,7 @@ class TestWorkflowEntryTracing:
|
||||
layer = MagicMock()
|
||||
|
||||
class FakeNode:
|
||||
def ensure_execution_id(self):
|
||||
def bind_execution_id(self, _execution_id):
|
||||
return None
|
||||
|
||||
def run(self):
|
||||
@@ -979,7 +774,7 @@ class TestWorkflowEntryTracing:
|
||||
layer = MagicMock()
|
||||
|
||||
class FakeNode:
|
||||
def ensure_execution_id(self):
|
||||
def bind_execution_id(self, _execution_id):
|
||||
return None
|
||||
|
||||
def run(self):
|
||||
|
||||
@@ -27,6 +27,7 @@ from libs.broadcast_channel.redis.sharded_channel import (
|
||||
ShardedTopic,
|
||||
_RedisShardedSubscription,
|
||||
)
|
||||
from libs.broadcast_channel.signals import SIG_CLOSE
|
||||
|
||||
|
||||
class TestBroadcastChannel:
|
||||
@@ -1239,6 +1240,30 @@ class TestRedisSubscriptionCommon:
|
||||
subscription_type, _ = subscription_params
|
||||
assert subscription._get_subscription_type() == subscription_type
|
||||
|
||||
def test_listener_ignores_close_signal_from_another_subscription(self, subscription, subscription_params):
|
||||
subscription_type, _ = subscription_params
|
||||
topic = f"test-{subscription_type}-topic"
|
||||
message_type = "message" if subscription_type == "regular" else "smessage"
|
||||
messages = iter(
|
||||
[
|
||||
{"type": message_type, "channel": topic, "data": SIG_CLOSE},
|
||||
{"type": message_type, "channel": topic, "data": b"next-event"},
|
||||
]
|
||||
)
|
||||
|
||||
def get_message():
|
||||
try:
|
||||
return next(messages)
|
||||
except StopIteration:
|
||||
subscription._closed.set()
|
||||
return None
|
||||
|
||||
subscription._get_message = get_message
|
||||
subscription._listen()
|
||||
|
||||
assert subscription._queue.get_nowait() == b"next-event"
|
||||
assert subscription._queue.empty()
|
||||
|
||||
# ==================== Lifecycle Tests ====================
|
||||
|
||||
def test_start_if_needed_first_call(self, subscription, subscription_params, mock_pubsub: MagicMock):
|
||||
|
||||
@@ -12,6 +12,7 @@ from libs.broadcast_channel.redis.streams_channel import (
|
||||
StreamsTopic,
|
||||
_StreamsSubscription,
|
||||
)
|
||||
from libs.broadcast_channel.signals import SIG_CLOSE
|
||||
|
||||
|
||||
class FakeStreamsRedis:
|
||||
@@ -282,6 +283,34 @@ class TestStreamsSubscription:
|
||||
|
||||
assert received == case.expected_messages
|
||||
|
||||
def test_listener_ignores_close_signal_from_another_subscription(self):
|
||||
class OneShotRedis:
|
||||
def __init__(self) -> None:
|
||||
self._calls = 0
|
||||
|
||||
def xread(self, streams: dict[str, Any], block: int | None = None, count: int | None = None):
|
||||
self._calls += 1
|
||||
if self._calls == 1:
|
||||
key = next(iter(streams))
|
||||
return [
|
||||
(
|
||||
key,
|
||||
[
|
||||
("1-0", {b"data": SIG_CLOSE}),
|
||||
("2-0", {b"data": b"next-event"}),
|
||||
],
|
||||
)
|
||||
]
|
||||
subscription._closed = True
|
||||
return []
|
||||
|
||||
subscription = _StreamsSubscription(OneShotRedis(), "stream:close-signal")
|
||||
subscription._listen()
|
||||
|
||||
assert subscription._queue.get_nowait() == b"next-event"
|
||||
assert subscription._queue.get_nowait() is subscription._SENTINEL
|
||||
assert subscription._queue.empty()
|
||||
|
||||
def test_iterator_yields_messages_until_subscription_is_closed(self, streams_channel: StreamsBroadcastChannel):
|
||||
topic = streams_channel.topic("iter")
|
||||
subscription = topic.subscribe()
|
||||
|
||||
@@ -123,12 +123,11 @@ def test_enable_disable_model_load_balancing_uses_model_type_constructor_directl
|
||||
method_name: str,
|
||||
expected_provider_method: str,
|
||||
service: ModelLoadBalancingService,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
provider_configuration = _build_provider_configuration(provider_schema=_build_provider_credential_schema())
|
||||
service.provider_manager.get_configurations.return_value = {"openai": provider_configuration}
|
||||
|
||||
getattr(service, method_name)("tenant-1", "openai", "gpt-4o-mini", "text-generation")
|
||||
getattr(service, method_name)("tenant-1", "openai", "gpt-4o-mini", "llm")
|
||||
|
||||
getattr(provider_configuration, expected_provider_method).assert_called_once_with(
|
||||
model="gpt-4o-mini", model_type=ModelType.LLM
|
||||
|
||||
@@ -377,7 +377,7 @@ class TestModelProviderServiceDelegation:
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"provider": "openai",
|
||||
"model_type": "text-generation",
|
||||
"model_type": "llm",
|
||||
"model": "gpt-4o",
|
||||
"credential_id": "cred-1",
|
||||
},
|
||||
@@ -389,7 +389,7 @@ class TestModelProviderServiceDelegation:
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"provider": "openai",
|
||||
"model_type": "text-generation",
|
||||
"model_type": "llm",
|
||||
"model": "gpt-4o",
|
||||
"credentials": {"api_key": "x"},
|
||||
"credential_name": "cred-a",
|
||||
@@ -407,7 +407,7 @@ class TestModelProviderServiceDelegation:
|
||||
{
|
||||
"tenant_id": "tenant-1",
|
||||
"provider": "openai",
|
||||
"model_type": "text-generation",
|
||||
"model_type": "llm",
|
||||
"model": "gpt-4o",
|
||||
},
|
||||
"delete_custom_model",
|
||||
|
||||
@@ -144,8 +144,7 @@ def _build_resumption_context(task_id: str, *, select_options: list[str] | None
|
||||
runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0)
|
||||
if select_options is not None:
|
||||
runtime_state.variable_pool.add(("start", "options"), select_options)
|
||||
runtime_state.register_paused_node("node-1")
|
||||
runtime_state.outputs = {"result": "value"}
|
||||
runtime_state.set_output("result", "value")
|
||||
wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity)
|
||||
return WorkflowResumptionContext(
|
||||
generate_entity=wrapper,
|
||||
@@ -250,7 +249,7 @@ def _build_resumption_context_additional(task_id: str) -> WorkflowResumptionCont
|
||||
workflow_execution_id="run-1",
|
||||
)
|
||||
runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0)
|
||||
runtime_state.outputs = {"answer": "ok"}
|
||||
runtime_state.set_output("answer", "ok")
|
||||
wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity)
|
||||
return WorkflowResumptionContext(
|
||||
generate_entity=wrapper,
|
||||
|
||||
+1
-1
@@ -74,7 +74,7 @@ def _build_resumption_context(task_id: str) -> WorkflowResumptionContext:
|
||||
workflow_execution_id="run-1",
|
||||
)
|
||||
runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0)
|
||||
runtime_state.outputs = {"answer": "ok"}
|
||||
runtime_state.set_output("answer", "ok")
|
||||
wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity)
|
||||
return WorkflowResumptionContext(
|
||||
generate_entity=wrapper,
|
||||
|
||||
Reference in New Issue
Block a user