diff --git a/api/tests/e2e_tests/contracts/core/workflow/graph_engine/test_parallel_human_input_join_resume.py b/api/tests/e2e_tests/contracts/core/workflow/graph_engine/test_parallel_human_input_join_resume.py index 30fe2e7276bf64..6ef585212af3ef 100644 --- a/api/tests/e2e_tests/contracts/core/workflow/graph_engine/test_parallel_human_input_join_resume.py +++ b/api/tests/e2e_tests/contracts/core/workflow/graph_engine/test_parallel_human_input_join_resume.py @@ -2,17 +2,8 @@ from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime, timedelta -from typing import Any, Protocol +from typing import Any, Protocol, cast -from core.repositories.human_input_repository import ( - FormCreateParams, - HumanInputFormEntity, - HumanInputFormRepository, -) -from core.workflow.node_runtime import DifyHumanInputNodeRuntime -from core.workflow.nodes.human_input.callback import ( - DifyHITLCallback, -) from core.workflow.system_variables import build_system_variables from enums.human_input import HumanInputFormStatus, ValueSourceType from graphon.engine import Engine @@ -29,11 +20,18 @@ from graphon.nodes.base.entities import OutputVariableEntity from graphon.nodes.end.end_node import EndNode from graphon.nodes.end.entities import EndNodeData +from graphon.nodes.human_input.entities import HumanInputNodeData as GraphonHumanInputNodeData from graphon.nodes.human_input.human_input_node import HumanInputNode from graphon.nodes.start.entities import StartNodeData from graphon.nodes.start.start_node import StartNode from graphon.runtime import RuntimeState, VariablePool from libs.datetime_utils import naive_utc_now +from models.human_input_contracts import ( + FormCreateParams, + HumanInputFormEntity, + HumanInputFormRecipientEntity, + HumanInputFormRepository, +) from models.human_input_entities import ( FileInputConfig, FileListInputConfig, @@ -42,6 +40,10 @@ StringListSource, UserActionConfig, ) +from services.workflow.execution.adapters.human_input import ( + DifyHITLCallback, +) +from services.workflow.execution.adapters.node_runtime import DifyFileReferenceFactory from tests.workflow_test_utils import build_test_graph_init_params @@ -94,11 +96,7 @@ def id(self) -> str: return self.form_id @property - def submission_token(self) -> str | None: - return "token" - - @property - def recipients(self) -> list: + def recipients(self) -> list[HumanInputFormRecipientEntity]: return [] @property @@ -134,7 +132,8 @@ class StaticRepo(HumanInputFormRepository): def __init__(self, forms_by_node_id: Mapping[str, HumanInputFormEntity]) -> None: self._forms_by_node_id = dict(forms_by_node_id) - def get_form(self, node_id: str) -> HumanInputFormEntity | None: + def get_form(self, node_id: str, *, form_id: str | None = None) -> HumanInputFormEntity | None: + _ = form_id return self._forms_by_node_id.get(node_id) def set_forms(self, forms_by_node_id: Mapping[str, HumanInputFormEntity]) -> None: @@ -197,32 +196,28 @@ def _build_graph(runtime_state: RuntimeState, repo: HumanInputFormRepository) -> ) human_a_config = {"id": "human_a", "data": human_data.model_dump()} - human_a_runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) - human_a_runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined] human_a_callback = DifyHITLCallback( form_repository=repo, node_data=human_data, - file_reference_factory=_TestFileReferenceFactory(), + file_reference_factory=cast(DifyFileReferenceFactory, _TestFileReferenceFactory()), ) human_a = HumanInputNode( node_id=human_a_config["id"], - data=human_data, + data=GraphonHumanInputNodeData.model_validate(human_data.model_dump()), init_params=graph_init_params, runtime_state=runtime_state, hitl_callback=human_a_callback, ) human_b_config = {"id": "human_b", "data": human_data.model_dump()} - human_b_runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) - human_b_runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined] human_b_callback = DifyHITLCallback( form_repository=repo, node_data=human_data, - file_reference_factory=_TestFileReferenceFactory(), + file_reference_factory=cast(DifyFileReferenceFactory, _TestFileReferenceFactory()), ) human_b = HumanInputNode( node_id=human_b_config["id"], - data=human_data, + data=GraphonHumanInputNodeData.model_validate(human_data.model_dump()), init_params=graph_init_params, runtime_state=runtime_state, hitl_callback=human_b_callback, diff --git a/api/tests/e2e_tests/contracts/core/workflow/graph_engine/test_tool_in_chatflow.py b/api/tests/e2e_tests/contracts/core/workflow/graph_engine/test_tool_in_chatflow.py index 5cf9b1e518c2a5..ab9687846d3da7 100644 --- a/api/tests/e2e_tests/contracts/core/workflow/graph_engine/test_tool_in_chatflow.py +++ b/api/tests/e2e_tests/contracts/core/workflow/graph_engine/test_tool_in_chatflow.py @@ -1,15 +1,16 @@ -from core.workflow.workflow_entry import iter_dify_graph_engine_events from graphon.engine import Engine from graphon.engine.command import InMemoryChannel from graphon.engine_events import ( GraphRunSucceededEvent, NodeRunStreamChunkEvent, ) +from services.workflow.execution.adapters.workflow_entry import iter_dify_graph_engine_events from tests.helpers.workflow.mock_config import MockConfigBuilder from tests.helpers.workflow.table_runner import TableTestRunner +from tests.unit_tests.workflow_execution import NO_HUMAN_INPUT_FORMS -def test_tool_in_chatflow(): +def test_tool_in_chatflow() -> None: runner = TableTestRunner() # Load the workflow configuration @@ -29,7 +30,7 @@ def test_tool_in_chatflow(): command_channel=InMemoryChannel(), ) - events = list(iter_dify_graph_engine_events(engine)) + events = list(iter_dify_graph_engine_events(engine, human_form_reader=NO_HUMAN_INPUT_FORMS)) # Check for successful completion success_events = [e for e in events if isinstance(e, GraphRunSucceededEvent)] @@ -42,7 +43,7 @@ def test_tool_in_chatflow(): assert success_events[-1].outputs["answer"] == "hello, dify!" -def test_answer_can_render_llm_structured_output_in_chatflow(): +def test_answer_can_render_llm_structured_output_in_chatflow() -> None: runner = TableTestRunner() fixture_data = runner.workflow_runner.load_fixture("basic_chatflow") @@ -81,7 +82,7 @@ def test_answer_can_render_llm_structured_output_in_chatflow(): command_channel=InMemoryChannel(), ) - events = list(iter_dify_graph_engine_events(engine)) + events = list(iter_dify_graph_engine_events(engine, human_form_reader=NO_HUMAN_INPUT_FORMS)) success_events = [e for e in events if isinstance(e, GraphRunSucceededEvent)] assert success_events, "Workflow should complete successfully" diff --git a/api/tests/e2e_tests/contracts/core/workflow/nodes/base/test_base_node.py b/api/tests/e2e_tests/contracts/core/workflow/nodes/base/test_base_node.py index ec4cef1955af13..47e229b4594a0c 100644 --- a/api/tests/e2e_tests/contracts/core/workflow/nodes/base/test_base_node.py +++ b/api/tests/e2e_tests/contracts/core/workflow/nodes/base/test_base_node.py @@ -1,9 +1,9 @@ import pytest -from core.workflow.node_factory import get_node_type_classes_mapping from graphon.entities.base_node_data import BaseNodeData from graphon.enums import BuiltinNodeTypes, NodeType from graphon.nodes.base.node import Node +from services.workflow.execution.adapters.node_factory import get_node_type_classes_mapping # Ensures that all production node classes are imported and registered. _ = get_node_type_classes_mapping() diff --git a/api/tests/e2e_tests/contracts/core/workflow/nodes/http_request/test_http_request_node.py b/api/tests/e2e_tests/contracts/core/workflow/nodes/http_request/test_http_request_node.py index 8a3b8419bcdbed..4b2839f9aa753a 100644 --- a/api/tests/e2e_tests/contracts/core/workflow/nodes/http_request/test_http_request_node.py +++ b/api/tests/e2e_tests/contracts/core/workflow/nodes/http_request/test_http_request_node.py @@ -7,13 +7,13 @@ from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from core.helper.ssrf_proxy import ssrf_proxy from core.tools.tool_file_manager import ToolFileManager -from core.workflow.node_runtime import DifyFileReferenceFactory from core.workflow.system_variables import build_system_variables from graphon.enums import WorkflowNodeExecutionStatus from graphon.file.file_manager import file_manager from graphon.nodes.http_request import HTTP_REQUEST_CONFIG_FILTER_KEY, HttpRequestNode, HttpRequestNodeConfig from graphon.nodes.http_request.entities import HttpRequestNodeData, HttpRequestNodeTimeout, Response from graphon.runtime import RuntimeState, VariablePool +from services.workflow.execution.adapters.node_runtime import DifyFileReferenceFactory from tests.workflow_test_utils import build_test_graph_init_params HTTP_REQUEST_CONFIG = HttpRequestNodeConfig( diff --git a/api/tests/e2e_tests/contracts/core/workflow/nodes/llm/test_node.py b/api/tests/e2e_tests/contracts/core/workflow/nodes/llm/test_node.py index d15f83dc7fb58b..7f6eaa2b4b28a6 100644 --- a/api/tests/e2e_tests/contracts/core/workflow/nodes/llm/test_node.py +++ b/api/tests/e2e_tests/contracts/core/workflow/nodes/llm/test_node.py @@ -19,7 +19,6 @@ from core.model_manager import ModelInstance from core.plugin.impl.model_runtime_factory import create_plugin_model_assembly from core.prompt.entities.advanced_prompt_entities import MemoryConfig -from core.workflow.node_runtime import DifyFileReferenceFactory, DifyPromptMessageSerializer, DifyToolFileManager from core.workflow.system_variables import default_system_variables from graphon.file import File, FileTransferMethod, FileType from graphon.model_runtime.entities.common_entities import I18nObject @@ -88,6 +87,11 @@ from graphon.template_rendering import TemplateRenderError from graphon.variables import ArrayAnySegment, ArrayFileSegment, NoneSegment from models.provider import ProviderType +from services.workflow.execution.adapters.node_runtime import ( + DifyFileReferenceFactory, + DifyPromptMessageSerializer, + DifyToolFileManager, +) from tests.workflow_test_utils import build_test_graph_init_params diff --git a/api/tests/e2e_tests/contracts/core/workflow/nodes/template_transform/test_template_transform_execution.py b/api/tests/e2e_tests/contracts/core/workflow/nodes/template_transform/test_template_transform_execution.py index 8069971e434613..178791db5bfc99 100644 --- a/api/tests/e2e_tests/contracts/core/workflow/nodes/template_transform/test_template_transform_execution.py +++ b/api/tests/e2e_tests/contracts/core/workflow/nodes/template_transform/test_template_transform_execution.py @@ -5,7 +5,6 @@ from typing import cast from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom -from core.workflow.node_factory import DifyNodeFactory from core.workflow.system_variables import build_system_variables from graphon.enums import WorkflowNodeExecutionStatus from graphon.graph import Graph @@ -13,6 +12,7 @@ from graphon.nodes.template_transform.template_transform_node import TemplateTransformNode from graphon.runtime import RuntimeState, VariablePool from graphon.template_rendering import Jinja2TemplateRenderer, TemplateRenderError +from services.workflow.execution.adapters.node_factory import DifyNodeFactory from tests.workflow_test_utils import build_test_graph_init_params diff --git a/api/tests/e2e_tests/contracts/core/workflow/nodes/test_base_node.py b/api/tests/e2e_tests/contracts/core/workflow/nodes/test_base_node.py index fdf206ba83d684..7b0148ef1c93c6 100644 --- a/api/tests/e2e_tests/contracts/core/workflow/nodes/test_base_node.py +++ b/api/tests/e2e_tests/contracts/core/workflow/nodes/test_base_node.py @@ -3,12 +3,12 @@ import pytest from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom -from core.workflow.node_runtime import resolve_dify_run_context from core.workflow.system_variables import build_system_variables from graphon.entities.base_node_data import BaseNodeData from graphon.enums import BuiltinNodeTypes from graphon.nodes.base.node import Node from graphon.runtime import InitParams, RuntimeState, VariablePool +from services.workflow.execution.adapters.node_runtime import resolve_dify_run_context from tests.workflow_test_utils import build_test_graph_init_params diff --git a/api/tests/e2e_tests/contracts/core/workflow/nodes/test_if_else.py b/api/tests/e2e_tests/contracts/core/workflow/nodes/test_if_else.py index d9c774c2ddb2f4..41e9c2cfce19d5 100644 --- a/api/tests/e2e_tests/contracts/core/workflow/nodes/test_if_else.py +++ b/api/tests/e2e_tests/contracts/core/workflow/nodes/test_if_else.py @@ -5,7 +5,6 @@ import pytest from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, InvokeFrom, UserFrom -from core.workflow.node_factory import DifyNodeFactory from core.workflow.system_variables import build_system_variables from graphon.enums import WorkflowNodeExecutionStatus from graphon.file import File, FileTransferMethod, FileType @@ -15,6 +14,7 @@ from graphon.runtime import RuntimeState, VariablePool from graphon.utils.condition.entities import Condition, SubCondition, SubVariableCondition from graphon.variables import ArrayFileSegment +from services.workflow.execution.adapters.node_factory import DifyNodeFactory from tests.workflow_test_utils import build_test_graph_init_params diff --git a/api/tests/e2e_tests/contracts/core/workflow/nodes/tool/test_tool_node.py b/api/tests/e2e_tests/contracts/core/workflow/nodes/tool/test_tool_node.py index c8ea5a542f5d84..361422f2a5d4b6 100644 --- a/api/tests/e2e_tests/contracts/core/workflow/nodes/tool/test_tool_node.py +++ b/api/tests/e2e_tests/contracts/core/workflow/nodes/tool/test_tool_node.py @@ -11,7 +11,6 @@ from pytest_mock import MockerFixture from core.tools.tool_file_manager import ToolFileManager -from core.workflow.node_runtime import DifyToolFileManager from core.workflow.system_variables import build_system_variables from graphon.file import File, FileTransferMethod, FileType from graphon.model_runtime.entities.llm_entities import LLMUsage @@ -20,6 +19,7 @@ from graphon.nodes.tool_runtime_entities import ToolRuntimeHandle, ToolRuntimeMessage from graphon.runtime import RuntimeState from graphon.variables.segments import ArrayFileSegment +from services.workflow.execution.adapters.node_runtime import DifyToolFileManager from tests.workflow_test_utils import build_test_graph_init_params, build_test_variable_pool if TYPE_CHECKING: # pragma: no cover - imported for type checking only diff --git a/api/tests/e2e_tests/contracts/core/workflow/test_node_mapping_bootstrap.py b/api/tests/e2e_tests/contracts/core/workflow/test_node_mapping_bootstrap.py index 45514949cb81ab..c18cc2218c5790 100644 --- a/api/tests/e2e_tests/contracts/core/workflow/test_node_mapping_bootstrap.py +++ b/api/tests/e2e_tests/contracts/core/workflow/test_node_mapping_bootstrap.py @@ -102,31 +102,34 @@ def test_moved_core_nodes_resolve_after_importing_production_entrypoints() -> No script = textwrap.dedent( """ from core.app.apps import workflow_app_runner - from core.workflow import workflow_entry + from core.workflow.nodes.datasource.datasource_node import DatasourceNode from core.workflow.nodes.knowledge_index import KNOWLEDGE_INDEX_NODE_TYPE - from core.workflow.node_factory import ( + from core.workflow.nodes.knowledge_index.knowledge_index_node import KnowledgeIndexNode + from services.workflow.execution.adapters import workflow_entry + from services.workflow.execution.adapters.knowledge_retrieval import KnowledgeRetrievalNode + from services.workflow.execution.adapters.node_factory import ( DifyNodeFactory, NODE_TYPE_CLASSES_MAPPING, resolve_workflow_node_class, ) - from core.workflow.nodes.agent import AgentNode - from core.workflow.nodes.agent_v2 import DifyAgentNode from graphon.enums import BuiltinNodeTypes from services import workflow_service from services.rag_pipeline import rag_pipeline + from services.workflow.execution.adapters.agent_node import AgentNode + from services.workflow.execution.adapters.agent_v2.agent_node import DifyAgentNode _ = workflow_entry, workflow_app_runner, workflow_service, rag_pipeline - expected = ( - BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL, - KNOWLEDGE_INDEX_NODE_TYPE, - BuiltinNodeTypes.DATASOURCE, - ) + expected = { + BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL: KnowledgeRetrievalNode, + KNOWLEDGE_INDEX_NODE_TYPE: KnowledgeIndexNode, + BuiltinNodeTypes.DATASOURCE: DatasourceNode, + } - for node_type in expected: + for node_type, node_class in expected.items(): assert node_type in NODE_TYPE_CLASSES_MAPPING, node_type resolved = DifyNodeFactory._resolve_node_class(node_type=node_type, node_version="1") - assert resolved.__module__.startswith("core.workflow.nodes."), resolved.__module__ + assert resolved is node_class, (node_type, resolved) assert DifyNodeFactory._resolve_node_class( node_type=BuiltinNodeTypes.AGENT, diff --git a/api/tests/helpers/workflow/mock_factory.py b/api/tests/helpers/workflow/mock_factory.py index 18ae1d0edd1148..93f61c05228946 100644 --- a/api/tests/helpers/workflow/mock_factory.py +++ b/api/tests/helpers/workflow/mock_factory.py @@ -8,11 +8,11 @@ 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 from graphon.entities.base_node_data import BaseNodeData from graphon.entities.graph_config import NodeConfigDict, NodeConfigDictAdapter from graphon.enums import BuiltinNodeTypes, NodeType from graphon.nodes.base.node import Node +from services.workflow.execution.adapters.node_factory import DifyNodeFactory from tests.helpers.workflow.mock_nodes import ( MockAgentNode, MockCodeNode, diff --git a/api/tests/helpers/workflow/mock_nodes.py b/api/tests/helpers/workflow/mock_nodes.py index 377494360ecbfe..5726fe7b00076e 100644 --- a/api/tests/helpers/workflow/mock_nodes.py +++ b/api/tests/helpers/workflow/mock_nodes.py @@ -12,17 +12,6 @@ from core.app.llm.model_access import DifyCredentialsProvider, DifyModelFactory from core.helper.ssrf_proxy import graphon_ssrf_proxy -from core.workflow.node_runtime import ( - DifyFileReferenceFactory, - DifyPreparedLLM, - DifyPromptMessageSerializer, - DifyToolFileManager, - DifyToolNodeRuntime, - build_dify_llm_file_saver, - resolve_dify_run_context, -) -from core.workflow.nodes.agent import AgentNode -from core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node import KnowledgeRetrievalNode from graphon.enums import WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus from graphon.model_runtime.entities.llm_entities import LLMUsage from graphon.node_events import NodeRunResult, StreamChunkEvent, StreamCompletedEvent @@ -37,6 +26,17 @@ from graphon.nodes.template_transform import TemplateTransformNode from graphon.nodes.tool import ToolNode from graphon.template_rendering import Jinja2TemplateRenderer, TemplateRenderError +from services.workflow.execution.adapters.agent_node import AgentNode +from services.workflow.execution.adapters.knowledge_retrieval import KnowledgeRetrievalNode +from services.workflow.execution.adapters.node_runtime import ( + DifyFileReferenceFactory, + DifyPreparedLLM, + DifyPromptMessageSerializer, + DifyToolFileManager, + DifyToolNodeRuntime, + build_dify_llm_file_saver, + resolve_dify_run_context, +) from tests.helpers.unit.core.model_fixtures import make_model_instance if TYPE_CHECKING: diff --git a/api/tests/helpers/workflow/table_runner.py b/api/tests/helpers/workflow/table_runner.py index bbe53521c827e6..24cbf533d7c256 100644 --- a/api/tests/helpers/workflow/table_runner.py +++ b/api/tests/helpers/workflow/table_runner.py @@ -21,10 +21,8 @@ from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, InvokeFrom, UserFrom from core.tools.utils.yaml_utils import _load_yaml_file -from core.workflow.node_factory import DifyNodeFactory, get_default_root_node_id from core.workflow.system_variables import build_bootstrap_variables, build_system_variables from core.workflow.variable_pool_initializer import add_node_inputs_to_pool, add_variables_to_pool -from core.workflow.workflow_entry import iter_dify_graph_engine_events from graphon.engine import Engine from graphon.engine.command import InMemoryChannel from graphon.engine_events import ( @@ -43,8 +41,11 @@ ObjectVariable, StringVariable, ) +from services.workflow.execution.adapters.node_factory import DifyNodeFactory, get_default_root_node_id +from services.workflow.execution.adapters.workflow_entry import iter_dify_graph_engine_events from tests.helpers.workflow.mock_config import MockConfig from tests.helpers.workflow.mock_factory import MockNodeFactory +from tests.unit_tests.workflow_execution import NO_HUMAN_INPUT_FORMS logger = logging.getLogger(__name__) @@ -107,7 +108,7 @@ def get_failed_results(self) -> list[WorkflowTestResult]: class WorkflowRunner: """Core workflow execution engine for tests.""" - def __init__(self, fixtures_dir: Path | None = None): + def __init__(self, fixtures_dir: Path | None = None) -> None: """Initialize the workflow runner.""" if fixtures_dir is None: # Use the new central fixtures location @@ -252,7 +253,7 @@ def __init__( enable_logging: bool = False, log_level: str = "INFO", graph_engine_max_workers: int = 1, - ): + ) -> None: """ Initialize the table test runner. @@ -306,6 +307,8 @@ def run_test_case(self, test_case: WorkflowTestCase) -> WorkflowTestResult: def _execute_test_case(self, test_case: WorkflowTestCase) -> WorkflowTestResult: """Internal method to execute a single test case.""" start_time = time.perf_counter() + graph: Graph | None = None + graph_runtime_state: RuntimeState | None = None try: graph, graph_runtime_state = self._create_graph_runtime_state(test_case) @@ -320,7 +323,7 @@ def _execute_test_case(self, test_case: WorkflowTestCase) -> WorkflowTestResult: # Execute and collect events events: list[EngineEvent] = [] - for event in iter_dify_graph_engine_events(engine): + for event in iter_dify_graph_engine_events(engine, human_form_reader=NO_HUMAN_INPUT_FORMS): events.append(event) # Check execution success @@ -386,8 +389,8 @@ def _execute_test_case(self, test_case: WorkflowTestCase) -> WorkflowTestResult: success=False, error=e, execution_time=time.perf_counter() - start_time, - graph=graph if "graph" in locals() else None, - graph_runtime_state=graph_runtime_state if "graph_runtime_state" in locals() else None, + graph=graph, + graph_runtime_state=graph_runtime_state, ) def _create_graph_runtime_state(self, test_case: WorkflowTestCase) -> tuple[Graph, RuntimeState]: @@ -453,7 +456,7 @@ def _validate_outputs( return True, None def _validate_event_sequence( - self, expected_sequence: list[type[EngineEvent]], actual_events: list[EngineEvent] + self, expected_sequence: Sequence[type[EngineEvent]], actual_events: list[EngineEvent] ) -> tuple[bool, str | None]: """ Validate that actual events match the expected event sequence. @@ -509,7 +512,7 @@ def run_table_tests( ) start_time = time.perf_counter() - results = [] + results: list[WorkflowTestResult] = [] if parallel and self.max_workers > 1: results = self._run_parallel(test_cases, fail_fast) diff --git a/api/tests/unit_tests/core/app/workflow/layers/test_observability.py b/api/tests/unit_tests/core/app/workflow/layers/test_observability.py index 55d75486f2c945..18823a54c602cb 100644 --- a/api/tests/unit_tests/core/app/workflow/layers/test_observability.py +++ b/api/tests/unit_tests/core/app/workflow/layers/test_observability.py @@ -9,14 +9,15 @@ from opentelemetry.trace import StatusCode, get_current_span from core.app.workflow.layers.observability import ObservabilityLayer -from core.workflow.workflow_entry import WorkflowEntry from extensions.otel.semconv import DifySpanAttributes from graphon.engine_events import GraphRunAbortedEvent, GraphRunSucceededEvent from graphon.graph import Graph from graphon.nodes.start import StartNode from graphon.nodes.start.entities import StartNodeData from graphon.runtime import RuntimeState, VariablePool +from services.workflow.execution.adapters.workflow_entry import WorkflowEntry from tests.helpers.unit.config_override import apply_config_overrides +from tests.unit_tests.workflow_execution import NO_HUMAN_INPUT_FORMS from tests.workflow_test_utils import build_test_graph_init_params @@ -113,7 +114,10 @@ def test_unbound_node_still_runs_and_preserves_body_errors(config_overrides, tra @pytest.mark.usefixtures("mock_is_instrument_flag_enabled_true", "tracer_provider_with_memory_exporter") def test_instrument_flag_enables_workflow_node_spans(memory_span_exporter, config_overrides, monkeypatch): config_overrides(ENABLE_OTEL=False, GRAPH_ENGINE_MAX_WORKERS=1) - monkeypatch.setattr("core.workflow.workflow_entry.is_instrument_flag_enabled", lambda: True) + monkeypatch.setattr( + "services.workflow.execution.adapters.workflow_entry.is_instrument_flag_enabled", + lambda: True, + ) state = RuntimeState(workflow_id="workflow", variable_pool=VariablePool(), start_at=0) node = StartNode( node_id="start", @@ -126,6 +130,7 @@ def test_instrument_flag_enables_workflow_node_spans(memory_span_exporter, confi graph=Graph.new().add_root(node).build(), call_depth=0, graph_runtime_state=state, + human_form_reader=NO_HUMAN_INPUT_FORMS, ) events = list(entry.run()) diff --git a/api/tests/unit_tests/core/workflow/nodes/agent/test_agent_node.py b/api/tests/unit_tests/core/workflow/nodes/agent/test_agent_node.py index 2ed54e5fadc8fc..70f042fcb4ba5b 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent/test_agent_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent/test_agent_node.py @@ -1,12 +1,12 @@ from unittest.mock import MagicMock -from core.workflow.nodes.agent.agent_node import AgentNode from core.workflow.nodes.agent.entities import AgentNodeData from core.workflow.nodes.agent.events import AgentLogEvent, NodeRunAgentLogEvent from graphon.engine_events import NodeRunStreamChunkEvent from graphon.enums import BuiltinNodeTypes from graphon.node_events import StreamChunkEvent from graphon.runtime import InitParams, RuntimeState, VariablePool +from services.workflow.execution.adapters.agent_node import AgentNode def test_dispatch_converts_agent_events_and_delegates_other_events() -> None: diff --git a/api/tests/unit_tests/core/workflow/nodes/agent/test_entities.py b/api/tests/unit_tests/core/workflow/nodes/agent/test_entities.py index 8257bbba16114c..358200d92d4e25 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent/test_entities.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent/test_entities.py @@ -1,10 +1,10 @@ from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, build_dify_run_context -from core.workflow.node_factory import DifyNodeFactory from graphon.engine import Engine from graphon.engine.command import InMemoryChannel from graphon.engine_events import GraphRunSucceededEvent, NodeEvent from graphon.graph import Graph from graphon.runtime import InitParams, RuntimeState, VariablePool +from services.workflow.execution.adapters.node_factory import DifyNodeFactory def test_unconfigured_disconnected_agent_does_not_block_workflow() -> None: diff --git a/api/tests/unit_tests/core/workflow/nodes/agent/test_runtime_support.py b/api/tests/unit_tests/core/workflow/nodes/agent/test_runtime_support.py index da54f8ee7e0939..588c6991bf3c66 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent/test_runtime_support.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent/test_runtime_support.py @@ -3,7 +3,6 @@ from core.entities.provider_entities import CustomProviderConfiguration from core.model_manager import ModelInstance from core.plugin.impl.model_runtime_factory import PluginModelAssembly -from core.workflow.nodes.agent.runtime_support import AgentRuntimeSupport from graphon.model_runtime.entities.common_entities import I18nObject from graphon.model_runtime.entities.model_entities import ( AIModelEntity, @@ -14,6 +13,7 @@ ParameterRule, ParameterType, ) +from services.workflow.execution.adapters.agent_runtime import AgentRuntimeSupport from tests.helpers.unit.core.model_fixtures import make_model_config @@ -27,7 +27,7 @@ def test_fetch_model_reuses_single_model_assembly(): with ( patch( - "core.workflow.nodes.agent.runtime_support.create_plugin_model_assembly", + "services.workflow.execution.adapters.agent_runtime.create_plugin_model_assembly", return_value=assembly, ) as mock_assembly, patch.object( diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py index 7a8f8c9ffc4c2a..b368fa5c1334b3 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_agent_node.py @@ -48,6 +48,7 @@ WorkflowAgentSessionScope, WorkflowAgentWorkspaceStore, ) +from extensions.application_services.workflow import WorkflowExecutionDependencies from graphon.enums import ( BuiltinNodeTypes, ErrorStrategy, @@ -75,6 +76,7 @@ WorkflowNodeJobConfig, ) from services.agent.workspace_service import AgentWorkspaceNotFoundError +from services.workflow.execution.adapters.agent_v2.agent_node import DifyAgentNode as ServiceDifyAgentNode @pytest.fixture(autouse=True) @@ -1115,13 +1117,23 @@ def test_agent_node_selects_the_configured_success_exit(enabled, count, output, ], ) def test_agent_routes_initialize_through_workflow_node_factory( - enabled, error_strategy, expected_execution_type, config_overrides -): - from core.workflow.node_factory import DifyNodeFactory + enabled: bool, + error_strategy: ErrorStrategy | None, + expected_execution_type: NodeExecutionType | None, + config_overrides: Callable[..., None], + workflow_runtime: WorkflowExecutionDependencies, +) -> None: + from services.workflow.execution.adapters.node_factory import DifyNodeFactory config_overrides(AGENT_BACKEND_USE_FAKE=True) - template = _node() - factory = DifyNodeFactory(template.init_params, template.runtime_state) + binding_resolver = FakeBindingResolver() + template = _node(binding_resolver=binding_resolver) + factory = DifyNodeFactory( + template.init_params, + template.runtime_state, + agent_binding_resolver=binding_resolver, + workflow_runtime=workflow_runtime, + ) node_data = template.node_data.model_dump(mode="python", by_alias=True) node_data.update( agent_output_routes={ @@ -1138,7 +1150,7 @@ def test_agent_routes_initialize_through_workflow_node_factory( return node = factory.create_node(node_config) - assert isinstance(node, DifyAgentNode) + assert isinstance(node, ServiceDifyAgentNode) assert node.execution_type == expected_execution_type assert node.node_data.agent_output_routes.enabled is enabled diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_dify_tools_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_dify_tools_builder.py index e85df517ef9967..2bad993bc97751 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_dify_tools_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_dify_tools_builder.py @@ -2,6 +2,7 @@ from collections.abc import Generator from typing import Any, override +from unittest.mock import create_autospec import pytest from sqlalchemy.orm import Session @@ -20,13 +21,18 @@ ToolProviderType, ) from core.tools.plugin_tool.tool import PluginTool -from core.tools.tool_manager import ToolManager -from core.workflow.nodes.agent_v2.dify_tools_builder import ( +from models.agent_config_entities import AgentKnowledgeSpaceConfig, AgentSoulToolsConfig +from models.tool_runtime_contracts import WorkflowToolQueries +from services.knowledge_fs.builtin_tool.knowledge_fs.knowledge_fs import KnowledgeFSProvider +from services.tools.tool_manager import ToolManager +from services.workflow.execution.adapters.agent_v2.dify_tools_builder import ( WorkflowAgentDifyToolsBuilder, WorkflowAgentDifyToolsBuildError, ) -from models.agent_config_entities import AgentKnowledgeSpaceConfig, AgentSoulToolsConfig -from services.knowledge_fs.builtin_tool.knowledge_fs.knowledge_fs import KnowledgeFSProvider + + +def _workflow_queries() -> WorkflowToolQueries: + return create_autospec(WorkflowToolQueries, instance=True, spec_set=True) class FakeRuntimeProvider: @@ -51,6 +57,9 @@ def get_agent_tool_runtime( variable_pool: Any | None = None, allow_file_parameters: bool = False, use_default_for_missing_form_parameters: bool = False, + *, + tool_providers, + workflow_queries: WorkflowToolQueries, ) -> Tool: self.call_count += 1 self.last_agent_tool = agent_tool @@ -238,9 +247,13 @@ def _build( return layers.plugin_tools -def test_builds_dify_plugin_tools_layer_from_existing_tool_runtime(): +def test_builds_dify_plugin_tools_layer_from_existing_tool_runtime(tool_providers): runtime_provider = FakeRuntimeProvider(_tool()) - builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) + builder = WorkflowAgentDifyToolsBuilder( + tool_runtime_provider=runtime_provider, + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -273,9 +286,13 @@ def test_builds_dify_plugin_tools_layer_from_existing_tool_runtime(): assert runtime_provider.last_agent_tool.provider_type.value == "plugin" -def test_normalizes_fully_qualified_builtin_plugin_provider_for_daemon_transport(): +def test_normalizes_fully_qualified_builtin_plugin_provider_for_daemon_transport(tool_providers): runtime_provider = FakeRuntimeProvider(_tool()) - builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) + builder = WorkflowAgentDifyToolsBuilder( + tool_runtime_provider=runtime_provider, + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -299,9 +316,13 @@ def test_normalizes_fully_qualified_builtin_plugin_provider_for_daemon_transport assert prepared.provider == "google" -def test_builds_core_tool_with_file_llm_parameter(): +def test_builds_core_tool_with_file_llm_parameter(tool_providers): runtime_provider = FakeRuntimeProvider(_file_tool()) - builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) + builder = WorkflowAgentDifyToolsBuilder( + tool_runtime_provider=runtime_provider, + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -334,9 +355,13 @@ def test_builds_core_tool_with_file_llm_parameter(): assert runtime_provider.last_use_default_for_missing_form_parameters is True -def test_builds_plugin_tool_with_file_llm_parameter_schema(): +def test_builds_plugin_tool_with_file_llm_parameter_schema(tool_providers): runtime_provider = FakeRuntimeProvider(_file_tool()) - builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) + builder = WorkflowAgentDifyToolsBuilder( + tool_runtime_provider=runtime_provider, + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -365,9 +390,13 @@ def test_builds_plugin_tool_with_file_llm_parameter_schema(): assert schema["required"] == ["audio_file"] -def test_builds_plugin_tool_with_files_llm_parameter_schema(): +def test_builds_plugin_tool_with_files_llm_parameter_schema(tool_providers): runtime_provider = FakeRuntimeProvider(_files_tool()) - builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) + builder = WorkflowAgentDifyToolsBuilder( + tool_runtime_provider=runtime_provider, + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -391,9 +420,13 @@ def test_builds_plugin_tool_with_files_llm_parameter_schema(): assert schema["required"] == ["documents"] -def test_builds_builtin_compat_plugin_tool_with_files_llm_parameter_schema(): +def test_builds_builtin_compat_plugin_tool_with_files_llm_parameter_schema(tool_providers): runtime_provider = FakeRuntimeProvider(_files_tool()) - builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) + builder = WorkflowAgentDifyToolsBuilder( + tool_runtime_provider=runtime_provider, + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -431,9 +464,13 @@ def test_builds_builtin_compat_plugin_tool_with_files_llm_parameter_schema(): assert runtime_provider.last_agent_tool.provider_type.value == "builtin" -def test_build_layers_routes_plugin_direct_and_builtin_via_core() -> None: +def test_build_layers_routes_plugin_direct_and_builtin_via_core(tool_providers) -> None: runtime_provider = FakeRuntimeProvider(_tool()) - builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) + builder = WorkflowAgentDifyToolsBuilder( + tool_runtime_provider=runtime_provider, + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -477,9 +514,13 @@ def test_build_layers_routes_plugin_direct_and_builtin_via_core() -> None: ("workflow", "workflow-provider-1"), ], ) -def test_build_layers_routes_api_and_workflow_via_core(provider_type: str, provider_id: str) -> None: +def test_build_layers_routes_api_and_workflow_via_core(tool_providers, provider_type: str, provider_id: str) -> None: runtime_provider = FakeRuntimeProvider(_tool()) - builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) + builder = WorkflowAgentDifyToolsBuilder( + tool_runtime_provider=runtime_provider, + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -508,40 +549,13 @@ def test_build_layers_routes_api_and_workflow_via_core(provider_type: str, provi assert [tool.tool_name for tool in result.core_tools.tools] == ["search"] -def test_build_layers_normalizes_mcp_provider_ids_to_server_identifier() -> None: +def test_build_layers_rejects_app_provider_type(tool_providers) -> None: runtime_provider = FakeRuntimeProvider(_tool()) builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=runtime_provider, - mcp_provider_id_resolver=lambda *, tenant_id, provider_id: "mcp-server-1", + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), ) - tools = AgentSoulToolsConfig.model_validate( - { - "dify_tools": [ - { - "provider_id": "db-row-id", - "provider_type": "mcp", - "tool_name": "search", - "credential_type": "unauthorized", - } - ] - } - ) - - result = builder.build_layers( - tenant_id="tenant-1", - app_id="app-1", - user_id="user-1", - tools=tools, - invoke_from=InvokeFrom.DEBUGGER, - ) - - assert result.core_tools is not None - assert result.core_tools.tools[0].provider_id == "mcp-server-1" - - -def test_build_layers_rejects_app_provider_type() -> None: - runtime_provider = FakeRuntimeProvider(_tool()) - builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -566,9 +580,13 @@ def test_build_layers_rejects_app_provider_type() -> None: assert runtime_provider.last_agent_tool is None -def test_build_layers_rejects_dataset_retrieval_provider_type() -> None: +def test_build_layers_rejects_dataset_retrieval_provider_type(tool_providers) -> None: runtime_provider = FakeRuntimeProvider(_tool()) - builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) + builder = WorkflowAgentDifyToolsBuilder( + tool_runtime_provider=runtime_provider, + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -595,9 +613,13 @@ def test_build_layers_rejects_dataset_retrieval_provider_type() -> None: assert runtime_provider.last_agent_tool is None -def test_builds_core_tool_with_missing_required_select_default(): +def test_builds_core_tool_with_missing_required_select_default(tool_providers): runtime_provider = FakeRuntimeProvider(_tts_tool()) - builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) + builder = WorkflowAgentDifyToolsBuilder( + tool_runtime_provider=runtime_provider, + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -626,9 +648,13 @@ def test_builds_core_tool_with_missing_required_select_default(): assert runtime_provider.last_use_default_for_missing_form_parameters is True -def test_rejects_duplicate_exposed_tool_names(): +def test_rejects_duplicate_exposed_tool_names(tool_providers): runtime_provider = FakeRuntimeProvider(_tool()) - builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) + builder = WorkflowAgentDifyToolsBuilder( + tool_runtime_provider=runtime_provider, + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -659,8 +685,12 @@ def test_rejects_duplicate_exposed_tool_names(): assert runtime_provider.call_count == 1 -def test_rejects_missing_required_runtime_parameter(): - builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(_tool(runtime_parameters={}))) +def test_rejects_missing_required_runtime_parameter(tool_providers): + builder = WorkflowAgentDifyToolsBuilder( + tool_runtime_provider=FakeRuntimeProvider(_tool(runtime_parameters={})), + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -682,11 +712,13 @@ def test_rejects_missing_required_runtime_parameter(): @pytest.mark.parametrize("tool_name", ["create_research_task", None]) -def test_research_tool_uses_sole_bound_agent_knowledge_space(tool_name: str | None) -> None: +def test_research_tool_uses_sole_bound_agent_knowledge_space(tool_providers, tool_name: str | None) -> None: tool = KnowledgeFSProvider().get_tool("create_research_task") assert tool is not None runtime_provider = FakeRuntimeProvider(tool) builder = WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), tool_runtime_provider=runtime_provider, provider_tools_lister=lambda **_kwargs: ["create_research_task"], ) @@ -731,10 +763,14 @@ def test_research_tool_uses_sole_bound_agent_knowledge_space(tool_name: str | No @pytest.mark.parametrize("tool_name", ["create_research_task", None]) -def test_research_tool_exposes_one_choice_per_bound_agent_knowledge_space(tool_name: str | None) -> None: +def test_research_tool_exposes_one_choice_per_bound_agent_knowledge_space( + tool_providers, tool_name: str | None +) -> None: tool = KnowledgeFSProvider().get_tool("create_research_task") assert tool is not None builder = WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), tool_runtime_provider=FakeRuntimeProvider(tool), provider_tools_lister=lambda **_kwargs: ["create_research_task"], ) @@ -795,10 +831,14 @@ def test_research_tool_exposes_one_choice_per_bound_agent_knowledge_space(tool_n assert "resource" not in prepared.parameters_json_schema["properties"] -def test_research_tool_without_single_bound_space_still_requires_resource() -> None: +def test_research_tool_without_single_bound_space_still_requires_resource(tool_providers) -> None: tool = KnowledgeFSProvider().get_tool("create_research_task") assert tool is not None - builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(tool)) + builder = WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), + tool_runtime_provider=FakeRuntimeProvider(tool), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -829,7 +869,7 @@ def test_research_tool_without_single_bound_space_still_requires_resource() -> N # ────────────────────────────────────────────────────────────────────────────── -def test_invoke_from_is_forwarded_to_tool_runtime_provider(): +def test_invoke_from_is_forwarded_to_tool_runtime_provider(tool_providers): """``WorkflowAgentRuntimeRequestBuilder`` passes the *real* runtime invocation source (DEBUGGER for draft test run, SERVICE_API for published run, etc.). ToolManager uses ``invoke_from`` for credential quotas / rate @@ -838,7 +878,11 @@ def test_invoke_from_is_forwarded_to_tool_runtime_provider(): representative invoke_from values.""" for invoke_from in (InvokeFrom.DEBUGGER, InvokeFrom.SERVICE_API, InvokeFrom.WEB_APP): runtime_provider = FakeRuntimeProvider(_tool()) - builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) + builder = WorkflowAgentDifyToolsBuilder( + tool_runtime_provider=runtime_provider, + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -864,9 +908,13 @@ def test_invoke_from_is_forwarded_to_tool_runtime_provider(): # ────────────────────────────────────────────────────────────────────────────── -def test_disabled_tools_are_skipped(): +def test_disabled_tools_are_skipped(tool_providers): runtime_provider = FakeRuntimeProvider(_tool()) - builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) + builder = WorkflowAgentDifyToolsBuilder( + tool_runtime_provider=runtime_provider, + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -889,11 +937,15 @@ def test_disabled_tools_are_skipped(): assert runtime_provider.last_agent_tool is None # ToolManager never queried -def test_plugin_id_plus_provider_fallback_when_provider_id_missing(): +def test_plugin_id_plus_provider_fallback_when_provider_id_missing(tool_providers): """Frontend may send ``plugin_id`` + ``provider`` instead of the concatenated ``provider_id``; the builder must accept both shapes.""" runtime_provider = FakeRuntimeProvider(_tool()) - builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) + builder = WorkflowAgentDifyToolsBuilder( + tool_runtime_provider=runtime_provider, + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -919,7 +971,7 @@ def test_plugin_id_plus_provider_fallback_when_provider_id_missing(): assert result.tools[0].provider == "search" -def test_unauthorized_tool_without_credentials(): +def test_unauthorized_tool_without_credentials(tool_providers): """``credential_type=unauthorized`` removes the ``credential_ref.id`` requirement (e.g. public Wikipedia / current_time tools).""" @@ -930,7 +982,11 @@ def _no_credentials_tool() -> FakeTool: return tool runtime_provider = FakeRuntimeProvider(_no_credentials_tool()) - builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider) + builder = WorkflowAgentDifyToolsBuilder( + tool_runtime_provider=runtime_provider, + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -973,22 +1029,26 @@ def _standard_tools_payload() -> AgentSoulToolsConfig: ) -def test_tool_provider_not_found_maps_to_declaration_not_found(): +def test_tool_provider_not_found_maps_to_declaration_not_found(tool_providers): from core.tools.errors import ToolProviderNotFoundError builder = WorkflowAgentDifyToolsBuilder( - tool_runtime_provider=FakeRuntimeProvider(ToolProviderNotFoundError("provider gone")) + tool_runtime_provider=FakeRuntimeProvider(ToolProviderNotFoundError("provider gone")), + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), ) with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info: _build(builder, _standard_tools_payload()) assert exc_info.value.error_code == "agent_tool_declaration_not_found" -def test_credential_validation_error_maps_to_credential_invalid(): +def test_credential_validation_error_maps_to_credential_invalid(tool_providers): from core.tools.errors import ToolProviderCredentialValidationError builder = WorkflowAgentDifyToolsBuilder( - tool_runtime_provider=FakeRuntimeProvider(ToolProviderCredentialValidationError("creds expired")) + tool_runtime_provider=FakeRuntimeProvider(ToolProviderCredentialValidationError("creds expired")), + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), ) with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info: _build(builder, _standard_tools_payload()) @@ -996,12 +1056,16 @@ def test_credential_validation_error_maps_to_credential_invalid(): assert "credential validation failed" in str(exc_info.value) -def test_generic_value_error_maps_to_config_invalid(): +def test_generic_value_error_maps_to_config_invalid(tool_providers): """Bare ``ValueError`` from ToolManager (e.g. "runtime not found") becomes ``agent_tool_config_invalid`` — distinct from ``agent_tool_declaration_not_found`` so callers can render a different hint.""" - builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(ValueError("runtime missing"))) + builder = WorkflowAgentDifyToolsBuilder( + tool_runtime_provider=FakeRuntimeProvider(ValueError("runtime missing")), + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), + ) with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info: _build(builder, _standard_tools_payload()) assert exc_info.value.error_code == "agent_tool_config_invalid" @@ -1012,7 +1076,7 @@ def test_generic_value_error_maps_to_config_invalid(): # ────────────────────────────────────────────────────────────────────────────── -def test_rejects_non_scalar_credential_value(): +def test_rejects_non_scalar_credential_value(tool_providers): """If a credential ever shows up shaped like ``{"access_token": "..."}``, ``str(value)`` would forward a Python repr to the plugin daemon. The builder should refuse and surface an explicit error code so an operator @@ -1025,7 +1089,11 @@ def _dict_credential_tool() -> FakeTool: tool.runtime.credentials = {"oauth": {"access_token": "secret", "expires_in": 3600}} return tool - builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=FakeRuntimeProvider(_dict_credential_tool())) + builder = WorkflowAgentDifyToolsBuilder( + tool_runtime_provider=FakeRuntimeProvider(_dict_credential_tool()), + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), + ) with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info: _build(builder, _standard_tools_payload()) assert exc_info.value.error_code == "agent_tool_credential_shape_invalid" @@ -1125,7 +1193,7 @@ def test_tool_scoped_provider_still_requires_credential_id(provider_type: str): # ── provider-level entries (tool_name omitted = all tools of the provider) ─── -def test_provider_level_entry_expands_to_all_tools(): +def test_provider_level_entry_expands_to_all_tools(tool_providers): runtime_provider = FakeRuntimeProvider(_tool()) listed: list[tuple[str, str, ToolProviderType]] = [] @@ -1133,7 +1201,12 @@ def lister(*, tenant_id: str, provider_type: ToolProviderType, provider_id: str) listed.append((tenant_id, provider_id, provider_type)) return ["search", "image_search"] - builder = WorkflowAgentDifyToolsBuilder(tool_runtime_provider=runtime_provider, provider_tools_lister=lister) + builder = WorkflowAgentDifyToolsBuilder( + tool_runtime_provider=runtime_provider, + provider_tools_lister=lister, + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -1153,10 +1226,12 @@ def lister(*, tenant_id: str, provider_type: ToolProviderType, provider_id: str) assert listed == [("tenant-1", "langgenius/search/search", ToolProviderType.PLUGIN)] -def test_explicit_tool_entry_wins_over_provider_expansion(): +def test_explicit_tool_entry_wins_over_provider_expansion(tool_providers): builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=FakeRuntimeProvider(_tool()), provider_tools_lister=lambda *, tenant_id, provider_type, provider_id: ["search", "image_search"], + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -1184,10 +1259,12 @@ def test_explicit_tool_entry_wins_over_provider_expansion(): assert sorted(tool.tool_name for tool in result.tools) == ["image_search", "search"] -def test_provider_level_entry_with_no_tools_maps_to_declaration_not_found(): +def test_provider_level_entry_with_no_tools_maps_to_declaration_not_found(tool_providers): builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=FakeRuntimeProvider(_tool()), provider_tools_lister=lambda *, tenant_id, provider_type, provider_id: [], + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -1206,14 +1283,17 @@ def test_provider_level_entry_with_no_tools_maps_to_declaration_not_found(): assert exc_info.value.error_code == "agent_tool_declaration_not_found" -def test_provider_level_entry_unknown_provider_maps_to_declaration_not_found(): +def test_provider_level_entry_unknown_provider_maps_to_declaration_not_found(tool_providers): from core.tools.errors import ToolProviderNotFoundError def lister(*, tenant_id: str, provider_type: ToolProviderType, provider_id: str) -> list[str]: raise ToolProviderNotFoundError("provider gone") builder = WorkflowAgentDifyToolsBuilder( - tool_runtime_provider=FakeRuntimeProvider(_tool()), provider_tools_lister=lister + tool_runtime_provider=FakeRuntimeProvider(_tool()), + provider_tools_lister=lister, + tool_providers=tool_providers, + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -1232,12 +1312,12 @@ def lister(*, tenant_id: str, provider_type: ToolProviderType, provider_id: str) assert exc_info.value.error_code == "agent_tool_declaration_not_found" -def test_list_provider_tool_names_reads_builtin_provider(monkeypatch: pytest.MonkeyPatch): +def test_list_provider_tool_names_reads_builtin_provider(tool_providers, monkeypatch: pytest.MonkeyPatch): """The default provider-tools lister maps ToolManager's provider controller to the plain name list the expansion step consumes.""" from types import SimpleNamespace - from core.workflow.nodes.agent_v2 import dify_tools_builder as module + from services.workflow.execution.adapters.agent_v2 import dify_tools_builder as module provider = SimpleNamespace( get_tools=lambda: [ @@ -1255,6 +1335,8 @@ def fake_get_builtin_provider(provider_id, tenant_id): monkeypatch.setattr(module.ToolManager, "get_builtin_provider", staticmethod(fake_get_builtin_provider)) names = module._list_provider_tool_names( + tool_providers=tool_providers, + queries=_workflow_queries(), tenant_id="tenant-1", provider_type=module.ToolProviderType.BUILT_IN, provider_id="langgenius/duckduckgo/duckduckgo", diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py index a9471bfe52346d..43243ef2538e1c 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_runtime_request_builder.py @@ -2,6 +2,7 @@ from dataclasses import replace from types import SimpleNamespace from typing import cast +from unittest.mock import create_autospec import pytest from dify_agent.layers.config import DifyConfigLayerConfig, DifyConfigSkillConfig @@ -22,14 +23,6 @@ from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom from core.ops.base_trace_instance import bind_application_tracing_hooks from core.workflow.file_reference import build_file_reference -from core.workflow.nodes.agent_v2.dify_tools_builder import WorkflowAgentDifyToolsBuilder -from core.workflow.nodes.agent_v2.runtime_request_builder import ( - WorkflowAgentRuntimeBuildContext, - WorkflowAgentRuntimeRequestBuilder, - WorkflowAgentRuntimeRequestBuildError, - build_knowledge_layer_config, - build_shell_layer_config, -) from graphon.file import File, FileTransferMethod, FileType from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment from models.agent import ( @@ -50,7 +43,16 @@ DeclaredOutputType, WorkflowNodeJobConfig, ) +from models.tool_runtime_contracts import WorkflowToolQueries from services.tracing.runtime_bridge import RuntimeApplicationTracingHooks +from services.workflow.execution.adapters.agent_v2.dify_tools_builder import WorkflowAgentDifyToolsBuilder +from services.workflow.execution.adapters.agent_v2.runtime_request_builder import ( + WorkflowAgentRuntimeBuildContext, + WorkflowAgentRuntimeRequestBuilder, + WorkflowAgentRuntimeRequestBuildError, + build_knowledge_layer_config, + build_shell_layer_config, +) from tests.helpers.unit.config_override import apply_config_overrides from tests.helpers.unit.knowledge_fs_core_fakes import RecordingModelFactory, llm_schema @@ -60,10 +62,14 @@ def _application_tracing_disabled(monkeypatch: pytest.MonkeyPatch) -> None: apply_config_overrides(monkeypatch, OPS_AGENT_TRACE_ENABLED=False) +def _workflow_queries() -> WorkflowToolQueries: + return create_autospec(WorkflowToolQueries, instance=True, spec_set=True) + + @pytest.fixture(autouse=True) def _no_runtime_agent_skills(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( - "core.workflow.nodes.agent_v2.runtime_request_builder.load_runtime_agent_skill_configs", + "services.workflow.execution.adapters.agent_v2.runtime_request_builder.load_runtime_agent_skill_configs", lambda **_kwargs: [], ) @@ -84,7 +90,7 @@ def resolve(*, run_context: object, provider_name: str, model_name: str) -> int: return 32_768 monkeypatch.setattr( - "core.workflow.nodes.agent_v2.runtime_request_builder.resolve_model_context_window", + "services.workflow.execution.adapters.agent_v2.runtime_request_builder.resolve_model_context_window", resolve, ) return calls @@ -247,7 +253,7 @@ def _context() -> WorkflowAgentRuntimeBuildContext: @pytest.mark.parametrize("preview", [False, True]) def test_builder_passes_inline_or_preview_context_to_application_tracing( - monkeypatch: pytest.MonkeyPatch, preview: bool + monkeypatch: pytest.MonkeyPatch, tool_providers, preview: bool ): from uuid import uuid4 @@ -275,7 +281,11 @@ def prepare(value: WorkflowAgentRuntimeBuildContext, conversation_id: str | None bind_application_tracing_hooks(app, hooks) with app.app_context(): - result = WorkflowAgentRuntimeRequestBuilder(dify_tools_builder=WorkflowAgentDifyToolsBuilder()).build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) layer = next(item for item in result.request.composition.layers if item.name == DIFY_EXECUTION_CONTEXT_LAYER_ID) assert DifyExecutionContextLayerConfig.model_validate(layer.config).tracing == tracing assert calls == [(context, None)] @@ -312,10 +322,15 @@ def _uploaded_workflow_files_prompt_payload(result) -> object: def test_builds_create_run_request_from_agent_soul_and_node_job( + tool_providers, model_context_window_calls: list[tuple[object, str, str]], ): context = _context() - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) dumped = result.request.model_dump(mode="json") layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]} @@ -429,7 +444,7 @@ def test_build_includes_core_tools_layer_returned_by_injected_builder(): pytest.param("langgenius/openai/openai", id="legacy-three-segment-provider-id"), ], ) -def test_normalizes_langgenius_model_provider_for_agent_backend_transport(plugin_id: str): +def test_normalizes_langgenius_model_provider_for_agent_backend_transport(tool_providers, plugin_id: str): context = _context() context.snapshot.config_snapshot = AgentSoulConfig( prompt={"system_prompt": "You are careful."}, @@ -439,7 +454,11 @@ def test_normalizes_langgenius_model_provider_for_agent_backend_transport(plugin model="gpt-test", ), ) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) dumped = result.request.model_dump(mode="json") layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]} @@ -448,7 +467,7 @@ def test_normalizes_langgenius_model_provider_for_agent_backend_transport(plugin assert model_config["model_provider"] == "openai" -def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metadata(): +def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metadata(tool_providers): context = _context() snapshot = AgentConfigSnapshot( id="snapshot-1", @@ -488,7 +507,11 @@ def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metada dify_context = context.dify_context.model_copy(update={"invoke_from": InvokeFrom.SERVICE_API}) context = replace(context, dify_context=dify_context, workflow_run_id=None, snapshot=snapshot, binding=binding) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) dumped = result.request.model_dump(mode="json") layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]} @@ -525,7 +548,7 @@ def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metada assert result.metadata["runtime_support"]["unsupported_runtime_warnings"] == [] -def test_build_maps_agent_soul_shell_settings_to_shell_layer(monkeypatch: pytest.MonkeyPatch): +def test_build_maps_agent_soul_shell_settings_to_shell_layer(tool_providers, monkeypatch: pytest.MonkeyPatch): apply_config_overrides(monkeypatch, AGENT_SHELL_ENABLED=True) context = _context() snapshot = AgentConfigSnapshot( @@ -547,7 +570,11 @@ def test_build_maps_agent_soul_shell_settings_to_shell_layer(monkeypatch: pytest ) context = replace(context, snapshot=snapshot) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) dumped = result.request.model_dump(mode="json") shell_config = {layer["name"]: layer for layer in dumped["composition"]["layers"]}[DIFY_SHELL_LAYER_ID]["config"] @@ -786,7 +813,7 @@ def test_builds_workflow_run_request_with_dify_plugin_tools_layer(monkeypatch: p assert layers[DIFY_EXECUTION_CONTEXT_LAYER_ID]["config"]["trace_id"] == "trace-session-1" -def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config(): +def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config(tool_providers): context = _context() snapshot = AgentConfigSnapshot( id="snapshot-1", @@ -867,9 +894,16 @@ def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config(): ) context = replace(context, snapshot=snapshot) - result = WorkflowAgentRuntimeRequestBuilder().build(context) - layers = result.request.model_dump(mode="json")["composition"]["layers"] - knowledge_layer = next(layer for layer in layers if layer["name"] == "knowledge") + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) + + dumped = result.request.model_dump(mode="json") + layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]} + knowledge_layer = layers["knowledge"] + assert knowledge_layer["config"]["execution_context"] == DIFY_EXECUTION_CONTEXT_LAYER_ID assert knowledge_layer["config"]["sets"] == [ { "id": "support", @@ -939,7 +973,7 @@ def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config(): assert knowledge_layer["config"]["max_observation_chars"] == 12000 -def test_build_knowledge_layer_maps_disabled_score_threshold_to_zero(): +def test_build_knowledge_layer_maps_disabled_score_threshold_to_zero(tool_providers): context = _context() snapshot = AgentConfigSnapshot( id="snapshot-1", @@ -974,6 +1008,16 @@ def test_build_knowledge_layer_maps_disabled_score_threshold_to_zero(): ) context = replace(context, snapshot=snapshot) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) + + dumped = result.request.model_dump(mode="json") + knowledge_layer = next(layer for layer in dumped["composition"]["layers"] if layer["name"] == "knowledge") + assert knowledge_layer["config"]["sets"][0]["retrieval"]["score_threshold"] == 0.0 + legacy = build_knowledge_layer_config(AgentSoulConfig.model_validate(snapshot.config_snapshot_dict)) assert legacy is not None assert legacy.model_dump(mode="json")["sets"][0]["retrieval"]["score_threshold"] == 0.0 @@ -1036,7 +1080,7 @@ def test_build_runs_classic_and_agent_knowledge_together(monkeypatch: pytest.Mon ) -def test_build_skips_knowledge_layer_when_agent_soul_has_no_sets(): +def test_build_skips_knowledge_layer_when_agent_soul_has_no_sets(tool_providers): context = _context() snapshot = AgentConfigSnapshot( id="snapshot-1", @@ -1057,22 +1101,30 @@ def test_build_skips_knowledge_layer_when_agent_soul_has_no_sets(): ) context = replace(context, snapshot=snapshot) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) dumped = result.request.model_dump(mode="json") assert all(layer["name"] != "knowledge" for layer in dumped["composition"]["layers"]) -def test_build_passes_saved_session_snapshot_to_agent_backend_request(): +def test_build_passes_saved_session_snapshot_to_agent_backend_request(tool_providers): session_snapshot = SessionSnapshot(layers={}) context = replace(_context(), session_snapshot=session_snapshot) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) assert result.request.session_snapshot is session_snapshot -def test_requires_agent_soul_model_config(): +def test_requires_agent_soul_model_config(tool_providers): context = _context() snapshot = AgentConfigSnapshot( id="snapshot-1", @@ -1084,10 +1136,14 @@ def test_requires_agent_soul_model_config(): context = replace(context, snapshot=snapshot) with pytest.raises(WorkflowAgentRuntimeRequestBuildError, match="Agent Soul model"): - WorkflowAgentRuntimeRequestBuilder().build(context) + WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) -def test_missing_previous_node_output_fails_request_build(): +def test_missing_previous_node_output_fails_request_build(tool_providers): context = _context() binding = WorkflowAgentNodeBinding( workflow_version="draft", @@ -1108,12 +1164,16 @@ def test_missing_previous_node_output_fails_request_build(): context = replace(context, binding=binding) with pytest.raises(WorkflowAgentRuntimeRequestBuildError) as exc_info: - WorkflowAgentRuntimeRequestBuilder().build(context) + WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) assert exc_info.value.error_code == "missing_previous_node_output" -def test_invalid_previous_node_output_ref_fails_request_build(): +def test_invalid_previous_node_output_ref_fails_request_build(tool_providers): context = _context() binding = WorkflowAgentNodeBinding( workflow_version="draft", @@ -1134,12 +1194,16 @@ def test_invalid_previous_node_output_ref_fails_request_build(): context = replace(context, binding=binding) with pytest.raises(WorkflowAgentRuntimeRequestBuildError) as exc_info: - WorkflowAgentRuntimeRequestBuilder().build(context) + WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) assert exc_info.value.error_code == "invalid_previous_node_output_ref" -def test_empty_declared_outputs_omits_structured_output_layer(): +def test_empty_declared_outputs_omits_structured_output_layer(tool_providers): context = _context() binding = WorkflowAgentNodeBinding( workflow_version="draft", @@ -1155,12 +1219,16 @@ def test_empty_declared_outputs_omits_structured_output_layer(): ) context = replace(context, binding=binding) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) assert DIFY_AGENT_OUTPUT_LAYER_ID not in _request_layers(result) -def test_array_output_emits_typed_items_per_array_item(): +def test_array_output_emits_typed_items_per_array_item(tool_providers): context = _context() binding = WorkflowAgentNodeBinding( workflow_version="draft", @@ -1187,7 +1255,11 @@ def test_array_output_emits_typed_items_per_array_item(): ) context = replace(context, binding=binding) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) output_schema = result.request.model_dump(mode="json")["composition"]["layers"][-1]["config"]["json_schema"] assert output_schema["properties"]["text"] == {"type": "string"} @@ -1232,7 +1304,7 @@ def test_nested_declared_output_emits_object_and_array_child_schema(): assert schema["required"] == ["email", "addresses"] -def test_mentions_expand_in_soul_and_job_prompts_without_token_leak(): +def test_mentions_expand_in_soul_and_job_prompts_without_token_leak(tool_providers): """ENG-616: soul/output mentions expand, while frontend workflow markers stay literal in the workflow task layer and resolve under workflow context.""" context = _context() @@ -1251,7 +1323,11 @@ def test_mentions_expand_in_soul_and_job_prompts_without_token_leak(): } ) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) layers = _request_layers(result) agent_soul_prompt = layers["agent_soul_prompt"]["config"]["prefix"] @@ -1268,7 +1344,7 @@ def test_mentions_expand_in_soul_and_job_prompts_without_token_leak(): assert "{{#" not in user_prompt -def test_previous_node_file_output_uses_agent_stub_download_mapping_in_workflow_context(): +def test_previous_node_file_output_uses_agent_stub_download_mapping_in_workflow_context(tool_providers): file_reference = build_file_reference(record_id="tool-file-1") class FileVariablePool(FakeVariablePool): @@ -1295,7 +1371,11 @@ def get(self, selector): } ) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) assert _request_layers(result)["workflow_node_job_prompt"]["config"]["user"] == ( "Review {{#previous-node.report#}} before responding." @@ -1306,7 +1386,7 @@ def get(self, selector): } -def test_previous_node_file_mapping_strips_extra_fields_in_workflow_context(): +def test_previous_node_file_mapping_strips_extra_fields_in_workflow_context(tool_providers): file_reference = build_file_reference(record_id="tool-file-1") class FileMappingVariablePool(FakeVariablePool): @@ -1329,7 +1409,11 @@ def get(self, selector): } ) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) assert _previous_node_prompt_payload(result, "previous-node.report") == { "transfer_method": "tool_file", @@ -1337,7 +1421,7 @@ def get(self, selector): } -def test_scalar_previous_node_output_appears_in_workflow_context_section(): +def test_scalar_previous_node_output_appears_in_workflow_context_section(tool_providers): context = _context() context.binding.node_job_config = WorkflowNodeJobConfig.model_validate( { @@ -1345,7 +1429,11 @@ def test_scalar_previous_node_output_appears_in_workflow_context_section(): } ) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) user_prompt = _workflow_user_prompt(result) @@ -1353,7 +1441,7 @@ def test_scalar_previous_node_output_appears_in_workflow_context_section(): assert " - previous-node.text: Previous result" in user_prompt -def test_stale_previous_node_refs_are_ignored_when_workflow_prompt_has_no_frontend_markers(): +def test_stale_previous_node_refs_are_ignored_when_workflow_prompt_has_no_frontend_markers(tool_providers): context = _context() context.binding.node_job_config = WorkflowNodeJobConfig.model_validate( { @@ -1362,7 +1450,11 @@ def test_stale_previous_node_refs_are_ignored_when_workflow_prompt_has_no_fronte } ) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) assert _request_layers(result)["workflow_node_job_prompt"]["config"]["user"] == ( "Review the current request without upstream context." @@ -1370,7 +1462,7 @@ def test_stale_previous_node_refs_are_ignored_when_workflow_prompt_has_no_fronte assert "Previous node outputs:" not in _workflow_user_prompt(result) -def test_previous_node_file_array_uses_agent_stub_download_mappings_in_workflow_context(): +def test_previous_node_file_array_uses_agent_stub_download_mappings_in_workflow_context(tool_providers): first_reference = build_file_reference(record_id="tool-file-1") build_file_reference(record_id="tool-file-2") @@ -1410,7 +1502,11 @@ def get(self, selector): } ) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) assert _previous_node_prompt_payload(result, "previous-node.attachments") == [ { @@ -1424,7 +1520,7 @@ def get(self, selector): ] -def test_uploaded_workflow_files_are_included_without_prompt_marker(): +def test_uploaded_workflow_files_are_included_without_prompt_marker(tool_providers): file_reference = build_file_reference(record_id="uploaded-file-1") class UploadedFilesVariablePool(FakeVariablePool): @@ -1453,7 +1549,11 @@ def get(self, selector): } ) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) user_prompt = _workflow_user_prompt(result) assert "- Uploaded workflow files:" in user_prompt @@ -1466,7 +1566,7 @@ def get(self, selector): assert "Previous node outputs:" not in user_prompt -def test_previous_node_remote_url_file_mapping_is_not_truncated_in_workflow_context(): +def test_previous_node_remote_url_file_mapping_is_not_truncated_in_workflow_context(tool_providers): remote_url = "https://example.com/" + ("a" * 2100) + ".pdf" class LongRemoteUrlVariablePool(FakeVariablePool): @@ -1493,7 +1593,11 @@ def get(self, selector): } ) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) assert _previous_node_prompt_payload(result, "previous-node.report") == { "transfer_method": "remote_url", @@ -1520,7 +1624,7 @@ def _soul_with_config_assets() -> AgentSoulConfig: def test_build_config_layer_config_includes_soul_context_and_mentions(): - from core.workflow.nodes.agent_v2.runtime_request_builder import build_config_layer_config + from services.workflow.execution.adapters.agent_v2.runtime_request_builder import build_config_layer_config config, warnings = build_config_layer_config( _soul_with_config_assets(), @@ -1543,7 +1647,7 @@ def test_build_config_layer_config_includes_soul_context_and_mentions(): def test_build_config_layer_config_includes_runtime_agent_skills(): - from core.workflow.nodes.agent_v2.runtime_request_builder import build_config_layer_config + from services.workflow.execution.adapters.agent_v2.runtime_request_builder import build_config_layer_config soul = AgentSoulConfig( prompt={"system_prompt": "Use [§skill:workspace-skill:Workspace Skill§]."}, @@ -1567,7 +1671,7 @@ def test_build_config_layer_config_includes_runtime_agent_skills(): def test_build_config_layer_config_returns_empty_config_for_empty_agent_soul(): - from core.workflow.nodes.agent_v2.runtime_request_builder import build_config_layer_config + from services.workflow.execution.adapters.agent_v2.runtime_request_builder import build_config_layer_config soul = AgentSoulConfig( model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test") @@ -1589,10 +1693,14 @@ def test_build_config_layer_config_returns_empty_config_for_empty_agent_soul(): assert warnings == [] -def test_workflow_run_request_has_config_layer_with_empty_agent_soul(monkeypatch: pytest.MonkeyPatch): +def test_workflow_run_request_has_config_layer_with_empty_agent_soul(tool_providers, monkeypatch: pytest.MonkeyPatch): apply_config_overrides(monkeypatch, AGENT_SHELL_ENABLED=True) - result = WorkflowAgentRuntimeRequestBuilder().build(_context()) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(_context()) dumped = result.request.model_dump(mode="json") layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]} @@ -1611,12 +1719,16 @@ def test_workflow_run_request_has_config_layer_with_empty_agent_soul(monkeypatch assert layers[DIFY_SHELL_LAYER_ID]["config"]["runtime"] == "runtime" -def test_workflow_run_request_contains_config_layer(): +def test_workflow_run_request_contains_config_layer(tool_providers): """Contract test: locks the dify.config composition shape against cross-package drift.""" context = _context() context.snapshot.config_snapshot = _soul_with_config_assets() - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) dumped = result.request.model_dump(mode="json") layer_names = [layer["name"] for layer in dumped["composition"]["layers"]] @@ -1648,9 +1760,9 @@ def test_workflow_run_request_contains_config_layer(): assert warnings == [] -def test_workflow_run_request_includes_bound_workspace_skills(monkeypatch: pytest.MonkeyPatch): +def test_workflow_run_request_includes_bound_workspace_skills(tool_providers, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( - "core.workflow.nodes.agent_v2.runtime_request_builder.load_runtime_agent_skill_configs", + "services.workflow.execution.adapters.agent_v2.runtime_request_builder.load_runtime_agent_skill_configs", lambda **_kwargs: [ DifyConfigSkillConfig( name="workspace-skill", @@ -1666,7 +1778,11 @@ def test_workflow_run_request_includes_bound_workspace_skills(monkeypatch: pytes model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"), ) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) config = next(layer for layer in result.request.composition.layers if layer.name == DIFY_CONFIG_LAYER_ID) assert [skill.name for skill in DifyConfigLayerConfig.model_validate(config.config).skills] == ["workspace-skill"] @@ -1675,11 +1791,15 @@ def test_workflow_run_request_includes_bound_workspace_skills(monkeypatch: pytes assert PromptConfig.model_validate(soul_prompt.config).prefix == "Use workspace-skill." -def test_workflow_runtime_expands_config_mentions_in_agent_soul_prompt(): +def test_workflow_runtime_expands_config_mentions_in_agent_soul_prompt(tool_providers): context = _context() context.snapshot.config_snapshot = _soul_with_config_assets() - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) soul_prompt = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt") assert ( @@ -1688,7 +1808,7 @@ def test_workflow_runtime_expands_config_mentions_in_agent_soul_prompt(): assert "[§" not in PromptConfig.model_validate(soul_prompt.config).prefix -def test_workflow_runtime_missing_config_mentions_fall_back_to_label_then_name(): +def test_workflow_runtime_missing_config_mentions_fall_back_to_label_then_name(tool_providers): context = _context() context.snapshot.config_snapshot = AgentSoulConfig( prompt={ @@ -1699,7 +1819,11 @@ def test_workflow_runtime_missing_config_mentions_fall_back_to_label_then_name() model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"), ) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=_workflow_queries() + ) + ).build(context) soul_prompt = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt") assert PromptConfig.model_validate(soul_prompt.config).prefix == "Use Ghost Skill, Ghost File, and no-label.txt." @@ -1712,7 +1836,7 @@ def test_workflow_runtime_missing_config_mentions_fall_back_to_label_then_name() def test_build_config_layer_config_missing_mentions_warn_without_catalog(): - from core.workflow.nodes.agent_v2.runtime_request_builder import build_config_layer_config + from services.workflow.execution.adapters.agent_v2.runtime_request_builder import build_config_layer_config soul = AgentSoulConfig( model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"), @@ -1727,7 +1851,7 @@ def test_build_config_layer_config_missing_mentions_warn_without_catalog(): def test_build_config_layer_config_excludes_missing_assets_from_runtime(): - from core.workflow.nodes.agent_v2.runtime_request_builder import build_config_layer_config + from services.workflow.execution.adapters.agent_v2.runtime_request_builder import build_config_layer_config soul = AgentSoulConfig.model_validate( { diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py index f9b2fc7e07493c..830e8f06c32d8c 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_validators.py @@ -1,28 +1,21 @@ from datetime import UTC, datetime -from unittest.mock import Mock import pytest from sqlalchemy.orm import Session -from core.workflow.nodes.agent_v2.validators import ( - WorkflowAgentNodeValidationError, - WorkflowAgentNodeValidator, -) +from enums.agent import WorkflowAgentBindingType from extensions.storage.storage_type import StorageType -from models.agent import ( - Agent, - AgentConfigSnapshot, - AgentScope, - AgentSource, - AgentStatus, - WorkflowAgentBindingType, - WorkflowAgentNodeBinding, -) +from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus, WorkflowAgentNodeBinding from models.agent_config_entities import AgentSoulConfig, AgentSoulModelConfig, WorkflowNodeJobConfig from models.enums import CreatorUserRole from models.model import UploadFile from models.workflow import Workflow -from tests.helpers.unit.model_factories import make_workflow +from repositories.agent.workflow_binding_repository import WorkflowAgentBindingRepository, workflow_binding_scope +from services.agent.workflow_validator import ( + WorkflowAgentNodeValidationError, + WorkflowAgentNodeValidator, +) +from tests.helpers.unit.model_factories import make_dataset, make_workflow def _workflow(graph: dict) -> Workflow: @@ -143,7 +136,9 @@ def test_historical_agent_version_two_is_not_validated_as_dify_agent(unbound_ses "edges": [], } - WorkflowAgentNodeValidator.validate_published_workflow(session=unbound_session, workflow=_workflow(graph)) + WorkflowAgentNodeValidator(repository=WorkflowAgentBindingRepository(unbound_session)).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(graph)) + ) def _persist_validation_scope( @@ -187,14 +182,15 @@ def test_publish_validation_accepts_upstream_previous_output_ref(sqlite_session: ) _persist_validation_scope(sqlite_session, node_job=node_job) - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow( - _graph( - [ - {"source": "start", "target": "previous-node"}, - {"source": "previous-node", "target": "agent-node"}, - ] + WorkflowAgentNodeValidator(repository=WorkflowAgentBindingRepository(sqlite_session)).validate_published_workflow( + workflow=workflow_binding_scope( + _workflow( + _graph( + [ + {"source": "start", "target": "previous-node"}, + {"source": "previous-node", "target": "agent-node"}, + ] + ) ) ), ) @@ -215,9 +211,8 @@ def test_publish_validation_uses_active_snapshot_for_roster_agent(sqlite_session snapshot.id = "active-snapshot" _persist_validation_scope(sqlite_session, node_job=node_job, binding=binding, agent=agent, snapshot=snapshot) - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator(repository=WorkflowAgentBindingRepository(sqlite_session)).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) @@ -228,9 +223,10 @@ def test_publish_validation_rejects_unpublished_roster_agent(sqlite_session: Ses sqlite_session.commit() with pytest.raises(WorkflowAgentNodeValidationError, match="unpublished roster agent"): - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(sqlite_session) + ).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) @@ -241,23 +237,25 @@ def test_publish_validation_rejects_non_upstream_previous_output_ref(sqlite_sess _persist_validation_scope(sqlite_session, node_job=node_job) with pytest.raises(WorkflowAgentNodeValidationError, match="non-upstream"): - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow( - _graph( - [ - {"source": "start", "target": "agent-node"}, - {"source": "agent-node", "target": "later-node"}, - ] + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(sqlite_session) + ).validate_published_workflow( + workflow=workflow_binding_scope( + _workflow( + _graph( + [ + {"source": "start", "target": "agent-node"}, + {"source": "agent-node", "target": "later-node"}, + ] + ) ) ), ) def test_draft_validation_allows_unbound_agent_node(sqlite_session: Session): - WorkflowAgentNodeValidator.validate_draft_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator(repository=WorkflowAgentBindingRepository(sqlite_session)).validate_draft_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) @@ -267,9 +265,8 @@ def test_draft_validation_allows_missing_previous_node(sqlite_session: Session): ) _persist_validation_scope(sqlite_session, node_job=node_job) - WorkflowAgentNodeValidator.validate_draft_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator(repository=WorkflowAgentBindingRepository(sqlite_session)).validate_draft_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) @@ -279,20 +276,21 @@ def test_draft_validation_allows_non_upstream_previous_output_ref(sqlite_session ) _persist_validation_scope(sqlite_session, node_job=node_job) - WorkflowAgentNodeValidator.validate_draft_workflow( - session=sqlite_session, - workflow=_workflow( - _graph( - [ - {"source": "start", "target": "agent-node"}, - {"source": "agent-node", "target": "later-node"}, - ] + WorkflowAgentNodeValidator(repository=WorkflowAgentBindingRepository(sqlite_session)).validate_draft_workflow( + workflow=workflow_binding_scope( + _workflow( + _graph( + [ + {"source": "start", "target": "agent-node"}, + {"source": "agent-node", "target": "later-node"}, + ] + ) ) ), ) -def test_draft_validation_allows_missing_agent_soul_model(): +def test_draft_validation_allows_missing_agent_soul_model(sqlite_session: Session): node_job = WorkflowNodeJobConfig.model_validate({}) snapshot = AgentConfigSnapshot( id="snapshot-1", @@ -301,12 +299,10 @@ def test_draft_validation_allows_missing_agent_soul_model(): version=1, config_snapshot=AgentSoulConfig(), ) - session = Mock() - session.scalar.side_effect = [_binding(node_job), _agent(), snapshot] + _persist_validation_scope(sqlite_session, node_job=node_job, snapshot=snapshot) - WorkflowAgentNodeValidator.validate_draft_workflow( - session=session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator(repository=WorkflowAgentBindingRepository(sqlite_session)).validate_draft_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) @@ -315,17 +311,17 @@ def test_draft_validation_rejects_incomplete_previous_output_ref(sqlite_session: _persist_validation_scope(sqlite_session, node_job=node_job) with pytest.raises(WorkflowAgentNodeValidationError, match="incomplete previous node output ref"): - WorkflowAgentNodeValidator.validate_draft_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator(repository=WorkflowAgentBindingRepository(sqlite_session)).validate_draft_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) def test_publish_validation_requires_binding(sqlite_session: Session): with pytest.raises(WorkflowAgentNodeValidationError, match="requires a binding"): - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(sqlite_session) + ).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) @@ -341,9 +337,10 @@ def test_publish_validation_rejects_duplicate_output_names(sqlite_session: Sessi _persist_validation_scope(sqlite_session, node_job=node_job) with pytest.raises(WorkflowAgentNodeValidationError, match="duplicate output name"): - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(sqlite_session) + ).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) @@ -359,9 +356,10 @@ def test_publish_validation_rejects_missing_agent_soul_model(sqlite_session: Ses _persist_validation_scope(sqlite_session, node_job=node_job, snapshot=snapshot) with pytest.raises(WorkflowAgentNodeValidationError, match="requires Agent Soul model"): - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(sqlite_session) + ).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) @@ -395,9 +393,10 @@ def test_publish_validation_dedupes_provider_level_tool_entries(sqlite_session: _persist_validation_scope(sqlite_session, node_job=node_job, snapshot=snapshot) with pytest.raises(WorkflowAgentNodeValidationError, match="duplicate Dify Plugin Tool"): - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(sqlite_session) + ).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) @@ -428,9 +427,8 @@ def test_publish_validation_accepts_provider_level_plus_explicit_tool_entry(sqli ) _persist_validation_scope(sqlite_session, node_job=node_job, snapshot=snapshot) - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator(repository=WorkflowAgentBindingRepository(sqlite_session)).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) @@ -448,9 +446,10 @@ def test_publish_validation_rejects_duplicate_cli_tool_names(sqlite_session: Ses _persist_validation_scope(sqlite_session, node_job=node_job, snapshot=snapshot) with pytest.raises(WorkflowAgentNodeValidationError, match="duplicate CLI Tool name pytest"): - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(sqlite_session) + ).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) @@ -468,9 +467,10 @@ def test_publish_validation_rejects_unauthorized_cli_tool(sqlite_session: Sessio _persist_validation_scope(sqlite_session, node_job=node_job, snapshot=snapshot) with pytest.raises(WorkflowAgentNodeValidationError, match="unauthorized CLI Tool"): - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(sqlite_session) + ).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) @@ -490,9 +490,10 @@ def test_publish_validation_rejects_unacknowledged_dangerous_cli_tool(sqlite_ses _persist_validation_scope(sqlite_session, node_job=node_job, snapshot=snapshot) with pytest.raises(WorkflowAgentNodeValidationError, match="unacknowledged dangerous CLI Tool"): - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(sqlite_session) + ).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) @@ -510,9 +511,10 @@ def test_publish_validation_rejects_unauthorized_secret_ref(sqlite_session: Sess _persist_validation_scope(sqlite_session, node_job=node_job, snapshot=snapshot) with pytest.raises(WorkflowAgentNodeValidationError, match="unauthorized secret reference API_TOKEN"): - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(sqlite_session) + ).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) @@ -540,9 +542,10 @@ def test_publish_validation_rejects_cli_tool_scoped_env_conflicts_and_unauthoriz binding, _, snapshot = _persist_validation_scope(sqlite_session, node_job=node_job, snapshot=snapshot) with pytest.raises(WorkflowAgentNodeValidationError, match="duplicate env/secret name TOKEN"): - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(sqlite_session) + ).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) snapshot.config_snapshot = AgentSoulConfig( @@ -566,9 +569,10 @@ def test_publish_validation_rejects_cli_tool_scoped_env_conflicts_and_unauthoriz sqlite_session.commit() with pytest.raises(WorkflowAgentNodeValidationError, match="unauthorized secret reference GITHUB_TOKEN"): - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(sqlite_session) + ).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) @@ -579,9 +583,10 @@ def test_publish_validation_rejects_missing_previous_node(sqlite_session: Sessio _persist_validation_scope(sqlite_session, node_job=node_job) with pytest.raises(WorkflowAgentNodeValidationError, match="references missing previous node"): - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(sqlite_session) + ).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) @@ -592,9 +597,10 @@ def test_publish_validation_rejects_self_previous_output_ref(sqlite_session: Ses _persist_validation_scope(sqlite_session, node_job=node_job) with pytest.raises(WorkflowAgentNodeValidationError, match="non-upstream"): - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(sqlite_session) + ).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) @@ -603,9 +609,10 @@ def test_publish_validation_rejects_locked_agent_soul_override_in_metadata(sqlit _persist_validation_scope(sqlite_session, node_job=node_job) with pytest.raises(WorkflowAgentNodeValidationError, match="cannot override locked Agent Soul fields"): - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(sqlite_session) + ).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) @@ -614,9 +621,10 @@ def test_publish_validation_rejects_invalid_human_contact_ref(sqlite_session: Se _persist_validation_scope(sqlite_session, node_job=node_job) with pytest.raises(WorkflowAgentNodeValidationError, match="invalid human contact ref"): - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(sqlite_session) + ).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) @@ -627,9 +635,10 @@ def test_publish_validation_rejects_out_of_scope_human_contact_ref(sqlite_sessio _persist_validation_scope(sqlite_session, node_job=node_job) with pytest.raises(WorkflowAgentNodeValidationError, match="out-of-scope human contact"): - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(sqlite_session) + ).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) @@ -651,9 +660,8 @@ def test_publish_validation_accepts_tenant_scoped_file_ref(sqlite_session: Sessi ) _persist_validation_scope(sqlite_session, node_job=node_job, extras=(_upload_file(),)) - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator(repository=WorkflowAgentBindingRepository(sqlite_session)).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) @@ -662,74 +670,70 @@ def test_publish_validation_rejects_missing_file_ref(sqlite_session: Session): _persist_validation_scope(sqlite_session, node_job=node_job) with pytest.raises(WorkflowAgentNodeValidationError, match="missing or out-of-scope metadata file ref"): - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(sqlite_session) + ).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))), ) -def test_publish_validation_rejects_missing_or_out_of_scope_knowledge_datasets( - monkeypatch: pytest.MonkeyPatch, - sqlite_session: Session, -): +@pytest.mark.parametrize("owner", [None, "tenant-2", "tenant-1"]) +def test_publish_validation_checks_knowledge_dataset_ownership(sqlite_session: Session, owner: str | None): dataset_id = "550e8400-e29b-41d4-a716-446655440000" - node_job = WorkflowNodeJobConfig.model_validate({}) snapshot = _snapshot_with_knowledge_dataset(dataset_id) - _persist_validation_scope(sqlite_session, node_job=node_job, snapshot=snapshot) - - captured = {} - - def fake_get_datasets_by_ids(ids, tenant_id, *, session): - captured["ids"] = ids - captured["tenant_id"] = tenant_id - return [], 0 - - import services.knowledge.dataset_service as dataset_service_module - - monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids) - - with pytest.raises(WorkflowAgentNodeValidationError, match=dataset_id): - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow(_graph([{"source": "start", "target": "agent-node"}])), - ) - - assert captured == {"ids": [dataset_id], "tenant_id": "tenant-1"} + _persist_validation_scope(sqlite_session, node_job=WorkflowNodeJobConfig(), snapshot=snapshot) + if owner is not None: + sqlite_session.add(make_dataset(dataset_id=dataset_id, tenant_id=owner)) + sqlite_session.commit() + validator = WorkflowAgentNodeValidator(repository=WorkflowAgentBindingRepository(sqlite_session)) + workflow = workflow_binding_scope(_workflow(_graph([{"source": "start", "target": "agent-node"}]))) + if owner == "tenant-1": + validator.validate_published_workflow(workflow=workflow) + else: + with pytest.raises(WorkflowAgentNodeValidationError, match=dataset_id): + validator.validate_published_workflow(workflow=workflow) def test_publish_validation_accepts_tool_node_agentic_manual_mode(unbound_session: Session): - WorkflowAgentNodeValidator.validate_published_workflow( - session=unbound_session, - workflow=_workflow(_tool_graph({"agentic_mode": {"state": "manual"}})), + WorkflowAgentNodeValidator(repository=WorkflowAgentBindingRepository(unbound_session)).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(_tool_graph({"agentic_mode": {"state": "manual"}}))), ) def test_publish_validation_accepts_tool_node_agentic_parameter_draft(unbound_session: Session): - WorkflowAgentNodeValidator.validate_published_workflow( - session=unbound_session, - workflow=_workflow(_tool_graph({"agentic_mode": {"state": "agentic", "parameter_draft": {"query": "x"}}})), + WorkflowAgentNodeValidator(repository=WorkflowAgentBindingRepository(unbound_session)).validate_published_workflow( + workflow=workflow_binding_scope( + _workflow(_tool_graph({"agentic_mode": {"state": "agentic", "parameter_draft": {"query": "x"}}})) + ), ) def test_publish_validation_rejects_incomplete_tool_node_agentic_config(unbound_session: Session): with pytest.raises(WorkflowAgentNodeValidationError, match="incomplete agentic mode config"): - WorkflowAgentNodeValidator.validate_published_workflow( - session=unbound_session, - workflow=_workflow(_tool_graph({"agentic_mode": True})), + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(unbound_session) + ).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(_tool_graph({"agentic_mode": True}))), ) with pytest.raises(WorkflowAgentNodeValidationError, match="incomplete agentic mode config"): - WorkflowAgentNodeValidator.validate_published_workflow( - session=unbound_session, - workflow=_workflow(_tool_graph({"agentic_mode": {"state": "agentic", "complete": False}})), + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(unbound_session) + ).validate_published_workflow( + workflow=workflow_binding_scope( + _workflow(_tool_graph({"agentic_mode": {"state": "agentic", "complete": False}})) + ), ) def test_publish_validation_rejects_unauthorized_tool_node_agentic_config(unbound_session: Session): with pytest.raises(WorkflowAgentNodeValidationError, match="unauthorized agentic mode config"): - WorkflowAgentNodeValidator.validate_published_workflow( - session=unbound_session, - workflow=_workflow(_tool_graph({"agentic_mode": {"state": "agentic", "permission": {"allowed": False}}})), + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(unbound_session) + ).validate_published_workflow( + workflow=workflow_binding_scope( + _workflow(_tool_graph({"agentic_mode": {"state": "agentic", "permission": {"allowed": False}}})) + ), ) @@ -738,9 +742,13 @@ def test_incomplete_route_conditions_can_be_saved_but_not_published(sqlite_sessi job = WorkflowNodeJobConfig.model_validate({"output_routes": {"enabled": True, "routes": routes}}) _persist_validation_scope(sqlite_session, node_job=job) workflow = _workflow(_graph([{"source": "start", "target": "agent-node"}])) - WorkflowAgentNodeValidator.validate_draft_workflow(session=sqlite_session, workflow=workflow) + WorkflowAgentNodeValidator(repository=WorkflowAgentBindingRepository(sqlite_session)).validate_draft_workflow( + workflow=workflow_binding_scope(workflow) + ) with pytest.raises(ValueError, match="route"): - WorkflowAgentNodeValidator.validate_published_workflow(session=sqlite_session, workflow=workflow) + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(sqlite_session) + ).validate_published_workflow(workflow=workflow_binding_scope(workflow)) def test_route_conditions_must_reference_upstream_nodes(sqlite_session: Session): @@ -757,14 +765,17 @@ def test_route_conditions_must_reference_upstream_nodes(sqlite_session: Session) ) _persist_validation_scope(sqlite_session, node_job=job) with pytest.raises(WorkflowAgentNodeValidationError, match="non-upstream"): - WorkflowAgentNodeValidator.validate_published_workflow( - session=sqlite_session, - workflow=_workflow( - _graph( - [ - {"source": "start", "target": "agent-node"}, - {"source": "agent-node", "target": "later-node"}, - ] + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(sqlite_session) + ).validate_published_workflow( + workflow=workflow_binding_scope( + _workflow( + _graph( + [ + {"source": "start", "target": "agent-node"}, + {"source": "agent-node", "target": "later-node"}, + ] + ) ) ), ) @@ -783,4 +794,6 @@ def test_publishing_routes_rejects_node_level_default_values(sqlite_session: Ses graph = _graph([{"source": "start", "target": "agent-node"}]) graph["nodes"][2]["data"]["error_strategy"] = "default-value" with pytest.raises(WorkflowAgentNodeValidationError, match="default-value"): - WorkflowAgentNodeValidator.validate_published_workflow(session=sqlite_session, workflow=_workflow(graph)) + WorkflowAgentNodeValidator( + repository=WorkflowAgentBindingRepository(sqlite_session) + ).validate_published_workflow(workflow=workflow_binding_scope(_workflow(graph))) diff --git a/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node.py b/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node.py index b2c3482de8d657..428abb9d9385c0 100644 --- a/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node.py @@ -1,21 +1,20 @@ import time from collections.abc import Generator -from types import SimpleNamespace +from dataclasses import replace from typing import Never import pytest from pytest_mock import MockerFixture -from sqlalchemy import Engine -from sqlalchemy.orm import sessionmaker from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, InvokeFrom, UserFrom -from core.workflow.node_factory import DifyNodeFactory from core.workflow.nodes.datasource.datasource_node import DatasourceNode from core.workflow.nodes.datasource.entities import DatasourceNodeData from core.workflow.system_variables import build_system_variables +from extensions.application_services.workflow import WorkflowExecutionDependencies from graphon.enums import WorkflowNodeExecutionStatus from graphon.node_events import NodeRunResult, StreamChunkEvent, StreamCompletedEvent from graphon.runtime import RuntimeState, VariablePool +from services.workflow.execution.adapters.node_factory import DifyNodeFactory from tests.workflow_test_utils import build_test_graph_init_params @@ -116,7 +115,9 @@ def get_upload_file_by_id(cls, **_): @pytest.mark.parametrize("use_factory", [False, True]) -def test_node_integration_minimal_stream(mocker: MockerFixture, sqlite_engine: Engine, use_factory: bool) -> None: +def test_node_integration_minimal_stream( + mocker: MockerFixture, workflow_runtime: WorkflowExecutionDependencies, use_factory: bool +) -> None: variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( datasource_type="online_document", @@ -170,15 +171,12 @@ def get_upload_file_by_id(cls, **_: object) -> Never: datasource_name="ds", ) if use_factory: - database_client = sessionmaker(sqlite_engine) - mocker.patch("core.workflow.node_factory.get_session_maker", return_value=database_client) - build_credentials = mocker.patch( - "core.workflow.node_factory.build_data_source_credentials", - return_value=SimpleNamespace(providers=SimpleNamespace(get_datasource_credentials=load_credentials)), + factory = DifyNodeFactory( + init_params=graph_init_params, + runtime_state=graph_runtime_state, + workflow_runtime=replace(workflow_runtime, datasource_credentials=load_credentials), ) - factory = DifyNodeFactory(init_params=graph_init_params, runtime_state=graph_runtime_state) node = factory.create_node({"id": "n", "data": node_data.model_dump()}) - build_credentials.assert_called_once_with(database_client=database_client) assert isinstance(node, DatasourceNode) else: node = DatasourceNode( diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_callback.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_callback.py index 79a2b7354f549a..676453ab44ba59 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_callback.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_callback.py @@ -1,26 +1,20 @@ from __future__ import annotations import json -from dataclasses import dataclass, field from datetime import timedelta import pytest from pytest_mock import MockerFixture from sqlalchemy.orm import Session, sessionmaker -from core.repositories.human_input_repository import ( - FormCreateParams, - HumanInputFormEntity, - HumanInputFormRepositoryImpl, - HumanInputFormSubmissionRepository, -) -from core.workflow.nodes.human_input.callback import DifyHITLCallback from core.workflow.nodes.human_input.session_binding import SessionBinding from enums.human_input import HumanInputFormStatus, ValueSourceType +from graphon.nodes.human_input.entities import Completed, Expired, HITLContext, PauseRequested from graphon.runtime import VariablePool from graphon.variables.factory import build_segment from libs.datetime_utils import naive_utc_now from models.human_input import HumanInputForm +from models.human_input_contracts import FormCreateParams, HumanInputFormEntity from models.human_input_entities import ( HumanInputNodeData, ParagraphInputConfig, @@ -28,29 +22,32 @@ StringListSource, UserActionConfig, ) +from repositories.human_input.form_repository import ( + HumanInputFormRepositoryImpl, + HumanInputFormSubmissionRepository, +) +from services.workflow.execution.adapters.human_input import DifyHITLCallback -@dataclass(frozen=True, slots=True) -class _Context: - workflow_execution_id: str - node_id: str - node_title: str = "Human Input" - variable_pool: VariablePool = field(default_factory=VariablePool) - - -def _ctx(workflow_execution_id: str, node_id: str, node_title: str = "Human Input") -> _Context: - return _Context(workflow_execution_id=workflow_execution_id, node_id=node_id, node_title=node_title) +def _ctx(workflow_execution_id: str, node_id: str, node_title: str = "Human Input") -> HITLContext: + return HITLContext( + workflow_execution_id=workflow_execution_id, + node_id=node_id, + node_title=node_title, + variable_pool=VariablePool(), + ) @pytest.fixture def repository( sqlite_session_factory: sessionmaker[Session], - mocker: MockerFixture, ) -> HumanInputFormRepositoryImpl: - mocker.patch( - "core.repositories.human_input_repository.session_factory.create_session", side_effect=sqlite_session_factory + return HumanInputFormRepositoryImpl( + tenant_id="tenant-1", + app_id="app-1", + workflow_execution_id="run-1", + sessions=sqlite_session_factory, ) - return HumanInputFormRepositoryImpl(tenant_id="tenant-1", app_id="app-1", workflow_execution_id="run-1") @pytest.mark.parametrize( @@ -96,6 +93,7 @@ def test_dify_hitl_callback_creates_pause_requested_for_new_form( decision = callback(_ctx("run-1", "node-1")) + assert isinstance(decision, PauseRequested) assert decision == callback.pause_requested_type(session_id=create.spy_return.id) form = repository.get_form("node-1") assert form is not None @@ -127,7 +125,11 @@ def test_dify_hitl_callback_persists_variable_select_options( ), ) - callback(_Context(workflow_execution_id="run-1", node_id="node-1", variable_pool=variable_pool)) + callback( + HITLContext( + workflow_execution_id="run-1", node_id="node-1", node_title="Human Input", variable_pool=variable_pool + ) + ) params: FormCreateParams = create.call_args.args[0] select_input = params.form_config.inputs[0] @@ -194,6 +196,7 @@ def test_dify_hitl_callback_returns_completed_for_submitted_form( decision = callback(_ctx("run-1", "node-1")) + assert isinstance(decision, Completed) assert decision.selected_handle == "approve" assert decision.inputs == {"answer": build_segment("yes")} assert decision.outputs == { @@ -231,6 +234,7 @@ def test_dify_hitl_callback_returns_timeout_for_explicit_timeout_form( decision = callback(_ctx("run-1", "node-1")) + assert isinstance(decision, Expired) assert decision.selected_handle == "__timeout" assert decision.outputs == { "__action_id": build_segment(""), @@ -241,8 +245,14 @@ def test_dify_hitl_callback_returns_timeout_for_explicit_timeout_form( def _past_deadline_callback( session: Session, + sessions: sessionmaker[Session], ) -> tuple[DifyHITLCallback, HumanInputFormRepositoryImpl]: - repository = HumanInputFormRepositoryImpl(tenant_id="tenant-1", app_id="app-1", workflow_execution_id="run-1") + repository = HumanInputFormRepositoryImpl( + tenant_id="tenant-1", + app_id="app-1", + workflow_execution_id="run-1", + sessions=sessions, + ) callback = DifyHITLCallback( form_repository=repository, node_data=HumanInputNodeData( @@ -261,8 +271,10 @@ def _past_deadline_callback( return callback, repository -def test_dify_hitl_callback_persists_timeout_for_waiting_form_past_node_deadline(sqlite_session: Session) -> None: - callback, _ = _past_deadline_callback(sqlite_session) +def test_dify_hitl_callback_persists_timeout_for_waiting_form_past_node_deadline( + sqlite_session: Session, sqlite_session_factory: sessionmaker[Session] +) -> None: + callback, _ = _past_deadline_callback(sqlite_session, sqlite_session_factory) decision = callback(_ctx("run-1", "node-1")) @@ -270,6 +282,7 @@ def test_dify_hitl_callback_persists_timeout_for_waiting_form_past_node_deadline form = sqlite_session.get(HumanInputForm, "form-1") assert form is not None assert form.status == HumanInputFormStatus.TIMEOUT + assert isinstance(decision, Expired) assert decision.selected_handle == "__timeout" assert decision.outputs == { "__action_id": build_segment(""), @@ -280,16 +293,19 @@ def test_dify_hitl_callback_persists_timeout_for_waiting_form_past_node_deadline @pytest.mark.parametrize("terminal_status", [HumanInputFormStatus.SUBMITTED, HumanInputFormStatus.EXPIRED]) def test_dify_hitl_callback_timeout_uses_concurrent_terminal_state( - monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, terminal_status: HumanInputFormStatus + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_session_factory: sessionmaker[Session], + terminal_status: HumanInputFormStatus, ) -> None: - callback, repository = _past_deadline_callback(sqlite_session) + callback, repository = _past_deadline_callback(sqlite_session, sqlite_session_factory) get_form = repository.get_form def finish_after_read(node_id: str, *, form_id: str | None = None) -> HumanInputFormEntity | None: waiting_form = get_form(node_id, form_id=form_id) monkeypatch.setattr(repository, "get_form", get_form) if terminal_status == HumanInputFormStatus.SUBMITTED: - HumanInputFormSubmissionRepository().mark_submitted( + HumanInputFormSubmissionRepository(sessions=sqlite_session_factory).mark_submitted( form_id="form-1", recipient_id=None, selected_action_id="approve", @@ -307,6 +323,7 @@ def finish_after_read(node_id: str, *, form_id: str | None = None) -> HumanInput monkeypatch.setattr(repository, "get_form", finish_after_read) if terminal_status == HumanInputFormStatus.SUBMITTED: decision = callback(_ctx("run-1", "node-1")) + assert isinstance(decision, Completed) assert decision.selected_handle == "approve" assert decision.inputs == {"answer": build_segment("accepted before timeout")} else: diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py index 87464e3084b5f2..5be677b1eab8f0 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py @@ -9,8 +9,8 @@ from datetime import datetime as _entities_datetime from decimal import Decimal from types import SimpleNamespace -from typing import Any -from unittest.mock import MagicMock +from typing import Any, cast, override +from unittest.mock import MagicMock, create_autospec import pytest from flask import Flask @@ -20,16 +20,7 @@ from sqlalchemy.orm import Session, sessionmaker from core.app.app_config.entities import WorkflowUIBasedAppConfig -from core.app.apps.advanced_chat.generate_response_converter import AdvancedChatAppGenerateResponseConverter -from core.app.apps.advanced_chat.generate_task_pipeline import ( - AdvancedChatAppGenerateTaskPipeline, - ConversationSnapshot, - MessageSnapshot, - WorkflowSnapshot, -) -from core.app.apps.base_app_generator import BaseAppGenerator from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom -from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner from core.app.entities.app_invoke_entities import ( DIFY_RUN_CONTEXT_KEY, AdvancedChatAppGenerateEntity, @@ -43,22 +34,9 @@ QueueWorkflowStartedEvent, WorkflowQueueMessage, ) -from core.repositories.human_input_repository import ( - FormCreateParams, - HumanInputFormEntity, - HumanInputFormRecipientEntity, - HumanInputFormRepository, - HumanInputFormSubmissionRepository, -) -from core.workflow.node_runtime import DifyHumanInputNodeRuntime -from core.workflow.nodes.human_input.boundary import HumanInputFormEventFilter -from core.workflow.nodes.human_input.callback import ( - DifyHITLCallback, -) from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from core.workflow.nodes.human_input.session_binding import SessionBinding from core.workflow.system_variables import build_system_variables -from core.workflow.workflow_entry import WorkflowEntry, iter_dify_graph_engine_events from enums.human_input import ( ButtonStyle, DeliveryMethodType, @@ -76,6 +54,8 @@ GraphEdgeSkippedEvent, GraphEdgeTakenEvent, NodeRunHumanInputFormFilledEvent, + NodeRunStartedEvent, + NodeRunSucceededEvent, ) from graphon.entities import WorkflowStartReason from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey @@ -87,6 +67,7 @@ from graphon.nodes.answer.entities import AnswerNodeData from graphon.nodes.end.end_node import EndNode from graphon.nodes.end.entities import EndNodeData +from graphon.nodes.human_input.entities import HumanInputNodeData as GraphonHumanInputNodeData from graphon.nodes.human_input.human_input_node import HumanInputNode from graphon.nodes.protocols import FileReferenceFactoryProtocol from graphon.nodes.start.entities import StartNodeData @@ -100,15 +81,22 @@ from models.account import Account, TenantAccountJoin, TenantAccountRole from models.enums import ConversationFromSource, MessageStatus from models.human_input import HumanInputForm +from models.human_input_contracts import ( + FormCreateParams, + HumanInputFormEntity, + HumanInputFormFactory, + HumanInputFormRecipientEntity, + HumanInputFormRepository, +) from models.human_input_delivery import ( EmailDeliveryConfig, EmailDeliveryMethod, EmailRecipients, ExternalRecipient, + InteractiveSurfaceDeliveryMethod, MemberRecipient, + _InteractiveSurfaceDeliveryConfig, ) -from models.human_input_delivery import InteractiveSurfaceDeliveryMethod as WebAppDeliveryMethod -from models.human_input_delivery import _InteractiveSurfaceDeliveryConfig as _WebAppDeliveryConfig from models.human_input_entities import ( FileInputConfig, FileListInputConfig, @@ -120,8 +108,33 @@ StringSource, UserActionConfig, ) -from models.model import AppMode +from models.model import AppMode, Message +from repositories.human_input.form_repository import HumanInputFormSubmissionRepository +from services.app.generation.ports import ( + ConversationSnapshot, + MessageIdentity, + MessageSnapshot, + MessageUpdate, + WorkflowSnapshot, +) +from services.app.generation.response import convert_to_event_stream +from services.human_input.ports import HumanInputFormReader +from services.tools.provider_queries import ToolProviders +from services.workflow.execution.adapters.chatflow.generate_response_converter import ( + AdvancedChatAppGenerateResponseConverter, +) +from services.workflow.execution.adapters.chatflow.generate_task_pipeline import AdvancedChatAppGenerateTaskPipeline +from services.workflow.execution.adapters.events import WorkflowEventPublisher +from services.workflow.execution.adapters.human_input import ( + DifyHITLCallback, +) +from services.workflow.execution.adapters.human_input_events import HumanInputFormEventFilter +from services.workflow.execution.adapters.node_runtime import DifyFileReferenceFactory, DifyHumanInputNodeRuntime +from services.workflow.execution.adapters.workflow_entry import WorkflowEntry, iter_dify_graph_engine_events +from services.workflow.execution.chatflow_ports import ChatflowRecords +from services.workflow.execution.ports import ExecutionContexts from tests.helpers.unit.model_factories import make_account, make_message, make_tenant +from tests.unit_tests.workflow_execution import NO_HUMAN_INPUT_FORMS def test_session_binding_identity_mapping() -> None: @@ -243,16 +256,10 @@ def test_custom_file_input_requires_extensions() -> None: ) -""" -Unit tests for human input node entities. -""" - - @dataclass class _InMemoryFormEntity(HumanInputFormEntity): form_id: str rendered: str - token: str | None = None action_id: str | None = None data: Mapping[str, Any] | None = None is_submitted: bool = False @@ -264,10 +271,6 @@ class _InMemoryFormEntity(HumanInputFormEntity): def id(self) -> str: return self.form_id - @property - def submission_token(self) -> str | None: - return self.token - @property def recipients(self) -> list[HumanInputFormRecipientEntity]: return [] @@ -317,7 +320,6 @@ def create_form(self, params: FormCreateParams) -> HumanInputFormEntity: entity = _InMemoryFormEntity( form_id=form_id, rendered=params.rendered_content, - token=f"token-{form_id}", ) self.created_forms.append(entity) self._forms_by_node_id[params.node_id] = entity @@ -389,11 +391,11 @@ class TestDeliveryMethod: def test_webapp_delivery_method(self): """Test webapp delivery method creation.""" - delivery_method = WebAppDeliveryMethod(enabled=True, config=_WebAppDeliveryConfig()) + delivery_method = InteractiveSurfaceDeliveryMethod(enabled=True, config=_InteractiveSurfaceDeliveryConfig()) assert delivery_method.type == DeliveryMethodType.WEBAPP assert delivery_method.enabled is True - assert isinstance(delivery_method.config, _WebAppDeliveryConfig) + assert isinstance(delivery_method.config, _InteractiveSurfaceDeliveryConfig) def test_email_delivery_method(self): """Test email delivery method creation.""" @@ -500,7 +502,7 @@ class TestHumanInputNodeData: def test_valid_node_data_creation(self): """Test creating valid human input node data.""" - delivery_methods = [WebAppDeliveryMethod(enabled=True, config=_WebAppDeliveryConfig())] + delivery_methods = [InteractiveSurfaceDeliveryMethod(enabled=True, config=_InteractiveSurfaceDeliveryConfig())] inputs = [ ParagraphInputConfig( @@ -534,7 +536,7 @@ def test_valid_node_data_creation(self): def test_node_data_with_multiple_delivery_methods(self): """Test node data with multiple delivery methods.""" delivery_methods = [ - WebAppDeliveryMethod(enabled=True, config=_WebAppDeliveryConfig()), + InteractiveSurfaceDeliveryMethod(enabled=True, config=_InteractiveSurfaceDeliveryConfig()), EmailDeliveryMethod( enabled=False, # Disabled method should be fine config=EmailDeliveryConfig( @@ -656,11 +658,7 @@ class TestHumanInputNodeVariableResolution: """Tests for resolving variable-based defaults in HumanInputNode.""" @pytest.fixture(autouse=True) - def _bind_repository(self, sqlite_session_factory: sessionmaker[Session], mocker: MockerFixture) -> None: - mocker.patch( - "core.repositories.human_input_repository.session_factory.create_session", - side_effect=sqlite_session_factory, - ) + def _bind_repository(self, sqlite_session_factory: sessionmaker[Session]) -> None: with sqlite_session_factory.begin() as session: session.add_all( [ @@ -670,7 +668,7 @@ def _bind_repository(self, sqlite_session_factory: sessionmaker[Session], mocker ] ) - def test_resolves_variable_defaults(self, mocker: MockerFixture): + def test_resolves_variable_defaults(self, mocker: MockerFixture, human_forms: HumanInputFormFactory): variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="user", @@ -717,11 +715,16 @@ def test_resolves_variable_defaults(self, mocker: MockerFixture): runtime = DifyHumanInputNodeRuntime( graph_init_params.run_context, + forms=human_forms, workflow_execution_id_getter=lambda: "exec-1", ) repository = runtime.build_form_repository() create = mocker.spy(repository, "create_form") - runtime = runtime.with_form_repository(repository) + runtime = DifyHumanInputNodeRuntime( + graph_init_params.run_context, + forms=lambda **_kwargs: repository, + workflow_execution_id_getter=lambda: "exec-1", + ) node = _build_human_input_node( node_id=config["id"], node_data=config["data"], @@ -741,7 +744,7 @@ def test_resolves_variable_defaults(self, mocker: MockerFixture): params = create.call_args.args[0] assert params.resolved_default_values == expected_values - def test_debugger_falls_back_to_recipient_token_when_webapp_disabled(self, mocker: MockerFixture): + def test_debugger_falls_back_to_recipient_token_when_webapp_disabled(self, human_forms: HumanInputFormFactory): variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="user", @@ -778,11 +781,9 @@ def test_debugger_falls_back_to_recipient_token_when_webapp_disabled(self, mocke runtime = DifyHumanInputNodeRuntime( graph_init_params.run_context, + forms=human_forms, workflow_execution_id_getter=lambda: "exec-2", ) - repository = runtime.build_form_repository() - mocker.spy(repository, "create_form") - runtime = runtime.with_form_repository(repository) node = _build_human_input_node( node_id=config["id"], node_data=config["data"], @@ -797,7 +798,9 @@ def test_debugger_falls_back_to_recipient_token_when_webapp_disabled(self, mocke assert isinstance(pause_event, PauseRequestedEvent) assert not hasattr(pause_event.reason, "form_token") - def test_webapp_runtime_keeps_form_visible_in_ui_when_webapp_delivery_is_enabled(self, mocker: MockerFixture): + def test_webapp_runtime_keeps_form_visible_in_ui_when_webapp_delivery_is_enabled( + self, mocker: MockerFixture, human_forms: HumanInputFormFactory + ): variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="user", @@ -838,11 +841,16 @@ def test_webapp_runtime_keeps_form_visible_in_ui_when_webapp_delivery_is_enabled runtime = DifyHumanInputNodeRuntime( graph_init_params.run_context, + forms=human_forms, workflow_execution_id_getter=lambda: "exec-4", ) repository = runtime.build_form_repository() create = mocker.spy(repository, "create_form") - runtime = runtime.with_form_repository(repository) + runtime = DifyHumanInputNodeRuntime( + graph_init_params.run_context, + forms=lambda **_kwargs: repository, + workflow_execution_id_getter=lambda: "exec-4", + ) node = _build_human_input_node( node_id=config["id"], node_data=config["data"], @@ -858,7 +866,9 @@ def test_webapp_runtime_keeps_form_visible_in_ui_when_webapp_delivery_is_enabled params = create.call_args.args[0] assert params.display_in_ui is True - def test_debugger_debug_mode_overrides_email_recipients(self, mocker: MockerFixture): + def test_debugger_debug_mode_overrides_email_recipients( + self, mocker: MockerFixture, human_forms: HumanInputFormFactory + ): variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( user_id="user-123", @@ -909,11 +919,16 @@ def test_debugger_debug_mode_overrides_email_recipients(self, mocker: MockerFixt runtime = DifyHumanInputNodeRuntime( graph_init_params.run_context, + forms=human_forms, workflow_execution_id_getter=lambda: "exec-3", ) repository = runtime.build_form_repository() create = mocker.spy(repository, "create_form") - runtime = runtime.with_form_repository(repository) + runtime = DifyHumanInputNodeRuntime( + graph_init_params.run_context, + forms=lambda **_kwargs: repository, + workflow_execution_id_getter=lambda: "exec-3", + ) node = _build_human_input_node( node_id=config["id"], node_data=config["data"], @@ -1008,8 +1023,7 @@ def test_replaces_outputs_placeholders_after_submission(self): config = {"id": "human", "data": node_data.model_dump()} form_repository = InMemoryHumanInputFormRepository() - runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) - runtime._build_form_repository = MagicMock(return_value=form_repository) # type: ignore[attr-defined] + runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context, forms=lambda **_kwargs: form_repository) node = _build_human_input_node( node_id=config["id"], node_data=config["data"], @@ -1081,8 +1095,7 @@ def test_resume_restores_file_outputs_as_runtime_segments(self): config = {"id": "human", "data": node_data.model_dump()} form_repository = InMemoryHumanInputFormRepository() - runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) - runtime._build_form_repository = MagicMock(return_value=form_repository) # type: ignore[attr-defined] + runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context, forms=lambda **_kwargs: form_repository) node = _build_human_input_node( node_id=config["id"], node_data=config["data"], @@ -1169,21 +1182,26 @@ def _persist_form( session.add(form) -class _FakeFormRepository: - def __init__(self, form): +class _FakeFormRepository(HumanInputFormRepository): + def __init__(self, form: HumanInputFormEntity) -> None: self._form = form - def get_form(self, *_args, **_kwargs): + def get_form(self, node_id: str, *, form_id: str | None = None) -> HumanInputFormEntity | None: + _ = (node_id, form_id) return self._form - def mark_timeout(self, _node_id: str, *, form_id: str): + def create_form(self, params: FormCreateParams) -> HumanInputFormEntity: + raise AssertionError(f"unexpected form creation: {params.node_id}") + + def mark_timeout(self, node_id: str, *, form_id: str) -> HumanInputFormEntity: + _ = node_id assert form_id == self._form.id - self._form.status = HumanInputFormStatus.TIMEOUT + cast(Any, self._form).status = HumanInputFormStatus.TIMEOUT return self._form class _HumanInputFormFilledEventTestfilereferencefactory(FileReferenceFactoryProtocol): - def build_from_mapping(self, *, mapping: Mapping[str, Any]): + def build_from_mapping(self, *, mapping: Mapping[str, Any]) -> File: return File( file_id=mapping.get("id"), file_type=FileType(mapping["type"]), @@ -1199,7 +1217,7 @@ def build_from_mapping(self, *, mapping: Mapping[str, Any]): def _create_human_input_node( *, - config: dict, + config: dict[str, Any], init_params: InitParams, runtime_state: RuntimeState, repo: _FakeFormRepository, @@ -1212,11 +1230,14 @@ def _create_human_input_node( callback = DifyHITLCallback( form_repository=repo, node_data=node_data, - file_reference_factory=_HumanInputFormFilledEventTestfilereferencefactory(), + file_reference_factory=cast( + DifyFileReferenceFactory, + _HumanInputFormFilledEventTestfilereferencefactory(), + ), ) node = HumanInputNode( node_id=config["id"], - data=node_data, + data=GraphonHumanInputNodeData.model_validate(node_data.model_dump()), init_params=init_params, runtime_state=runtime_state, hitl_callback=callback, @@ -1315,7 +1336,7 @@ def _build_node( config["data"]["inputs"] = [] fake_form.submitted_data = {} - repo = _FakeFormRepository(fake_form) + repo = _FakeFormRepository(cast(HumanInputFormEntity, fake_form)) return _create_human_input_node( config=config, init_params=graph_init_params, @@ -1378,7 +1399,7 @@ def _build_timeout_node( or naive_utc_now() - _human_input_form_filled_event_datetime.timedelta(minutes=1), ) - repo = _FakeFormRepository(fake_form) + repo = _FakeFormRepository(cast(HumanInputFormEntity, fake_form)) return _create_human_input_node( config=config, init_params=graph_init_params, @@ -1387,15 +1408,20 @@ def _build_timeout_node( ) -def _publish_node_events(node: HumanInputNode) -> list[AppQueueEvent]: +def _publish_node_events(node: HumanInputNode, *, sessions: sessionmaker[Session] | None = None) -> list[AppQueueEvent]: + form_reader = HumanInputFormSubmissionRepository(sessions=sessions) if sessions else NO_HUMAN_INPUT_FORMS return _publish_graph_events( - _filter_human_input_events(node.run(), node=node), - graph=Graph(root_node=node), + _filter_human_input_events(cast(Iterable[EngineEvent], node.run()), node=node, form_reader=form_reader), runtime_state=node.runtime_state, ) -def _filter_human_input_events(events: Iterable[EngineEvent], *, node: HumanInputNode | None = None): +def _filter_human_input_events( + events: Iterable[EngineEvent], + *, + node: HumanInputNode | None = None, + form_reader: HumanInputFormReader = NO_HUMAN_INPUT_FORMS, +) -> Iterable[EngineEvent]: node = node or _build_node() return filter_engine_events( events, @@ -1403,13 +1429,15 @@ def _filter_human_input_events(events: Iterable[EngineEvent], *, node: HumanInpu graph=Graph(root_node=node), runtime_state=ReadOnlyRuntimeStateWrapper(node.runtime_state), ), - filters=[HumanInputFormEventFilter(form_repository=HumanInputFormSubmissionRepository())], + filters=[HumanInputFormEventFilter(form_repository=form_reader)], ) @pytest.mark.parametrize("event_type", [GraphEdgeTakenEvent, GraphEdgeSkippedEvent]) -def test_human_input_filter_forwards_traversals_without_waiting_for_completion(event_type): - started, _ = list(_build_node().run()) +def test_human_input_filter_forwards_traversals_without_waiting_for_completion( + event_type: type[GraphEdgeTakenEvent | GraphEdgeSkippedEvent], +) -> None: + started = cast(NodeRunStartedEvent, list(_build_node().run())[0]) edge = event_type(frame_id=ROOT_FRAME_ID, edge_id="human-answer", source_node_id="node-1", target_node_id="answer") edge_forwarded = False @@ -1425,9 +1453,11 @@ def source() -> Generator[EngineEvent]: assert list(events) == [] -def test_form_events_keep_titles_for_interleaved_executions_of_one_node(): +def test_form_events_keep_titles_for_interleaved_executions_of_one_node() -> None: node = _build_node() - started, succeeded = list(node.run()) + raw_events = list(node.run()) + started = cast(NodeRunStartedEvent, raw_events[0]) + succeeded = cast(NodeRunSucceededEvent, raw_events[1]) first_start = started.model_copy(update={"id": "first", "node_title": "First approval"}) second_start = started.model_copy(update={"id": "second", "node_title": "Second approval"}) first_result = succeeded.model_copy( @@ -1455,7 +1485,7 @@ def test_form_events_keep_titles_for_interleaved_executions_of_one_node(): ("second", "iteration-2", second_result.node_run_result.metadata), ("first", "loop-1", first_result.node_run_result.metadata), ] - events = _publish_graph_events(filtered, graph=Graph(root_node=node), runtime_state=node.runtime_state) + events = _publish_graph_events(filtered, runtime_state=node.runtime_state) filled = [event for event in events if isinstance(event, QueueHumanInputFormFilledEvent)] assert [(event.form_id, event.node_title) for event in filled] == [ @@ -1477,29 +1507,53 @@ def __init__(self, invoke_from: InvokeFrom, runtime_state: RuntimeState) -> None self.graph_runtime_state = runtime_state self.events: list[AppQueueEvent] = [] + @override def _publish(self, event: AppQueueEvent, pub_from: PublishFrom) -> None: assert pub_from in {PublishFrom.APPLICATION_MANAGER, PublishFrom.TASK_PIPELINE} self.events.append(event) + @override def listen(self) -> Generator[WorkflowQueueMessage]: for event in self.events: yield WorkflowQueueMessage(task_id="task-1", app_mode=AppMode.ADVANCED_CHAT, event=event) -def _publish_graph_events( - events: Iterable[EngineEvent], *, graph: Graph, runtime_state: RuntimeState -) -> list[AppQueueEvent]: - queue_manager = _EventQueue(InvokeFrom.WEB_APP, runtime_state) - runner = WorkflowBasedAppRunner(queue_manager=queue_manager, app_id="app") - workflow_entry = WorkflowEntry( - graph_config={}, - graph=graph, - call_depth=0, - graph_runtime_state=runtime_state, +def _publish_graph_events(events: Iterable[EngineEvent], *, runtime_state: RuntimeState) -> list[AppQueueEvent]: + published: list[AppQueueEvent] = [] + queue_manager = create_autospec(AppQueueManager, instance=True, spec_set=True) + queue_manager.publish.side_effect = lambda event, _publish_from: published.append(event) + publisher = WorkflowEventPublisher(queue_manager, notify_pause=lambda _: None) + workflow_entry = cast( + WorkflowEntry, + SimpleNamespace(graph_engine=SimpleNamespace(runtime_state=runtime_state)), ) for event in events: - runner.handle_event(workflow_entry, event) - return queue_manager.events + publisher.publish(workflow_entry, event) + + return published + + +class StreamMessageWrites: + """The SSE contract tests replace persistence at its port; SQLite tests cover writes.""" + + def attach_workflow(self, identity: MessageIdentity, workflow_run_id: str) -> None: + pass + + def record_human_input( + self, + identity: MessageIdentity, + *, + workflow_run_id: str, + form_id: str | None, + node_id: str | None, + ) -> None: + pass + + def save_message(self, _identity: MessageIdentity, _update: MessageUpdate) -> Message: + return cast(Message, None) + + def fail_message(self, identity: MessageIdentity, error: str) -> None: + pass def _sse_payloads( @@ -1508,6 +1562,9 @@ def _sse_payloads( app: Flask, sessions: sessionmaker[Session], runtime_state: RuntimeState | None = None, + *, + workflow_contexts: ExecutionContexts, + tool_providers: ToolProviders, ) -> list[dict[str, Any]]: generate_entity = AdvancedChatAppGenerateEntity( task_id="task-1", @@ -1564,6 +1621,8 @@ def _sse_payloads( status=HumanInputFormStatus.SUBMITTED, ) pipeline = AdvancedChatAppGenerateTaskPipeline( + contexts=workflow_contexts, + chat_records=cast(ChatflowRecords, StreamMessageWrites()), application_generate_entity=generate_entity, workflow=WorkflowSnapshot(id="workflow", tenant_id="tenant", features_dict={}), queue_manager=queue_manager, @@ -1575,6 +1634,7 @@ def _sse_payloads( stream=True, dialogue_count=1, draft_var_saver_factory=MagicMock(), + tool_providers=tool_providers, ) with app.test_request_context(): if not any(isinstance(event, QueueWorkflowStartedEvent) for event in events): @@ -1584,7 +1644,7 @@ def _sse_payloads( ) ) response = compact_generate_response( - BaseAppGenerator.convert_to_event_stream( + convert_to_event_stream( AdvancedChatAppGenerateResponseConverter.convert( pipeline._to_stream_response(pipeline._process_stream_response()), invoke_from ) @@ -1598,11 +1658,23 @@ def _sse_payloads( @pytest.mark.parametrize("invoke_from", [InvokeFrom.DEBUGGER, InvokeFrom.WEB_APP], ids=["full", "simple"]) def test_submitted_human_input_reaches_response_stream( - invoke_from: InvokeFrom, app: Flask, sqlite_session_factory: sessionmaker[Session] -): + invoke_from: InvokeFrom, + app: Flask, + sqlite_session_factory: sessionmaker[Session], + *, + workflow_contexts: ExecutionContexts, + tool_providers: ToolProviders, +) -> None: events = _publish_node_events(_build_node()) - payloads = _sse_payloads(events, invoke_from, app, sqlite_session_factory) + payloads = _sse_payloads( + events, + invoke_from, + app, + sqlite_session_factory, + workflow_contexts=workflow_contexts, + tool_providers=tool_providers, + ) # Dify's original HumanInputNode (9c339239850) emitted the form event # before node completion; its runner and task pipeline preserved that order. @@ -1631,10 +1703,23 @@ def test_submitted_human_input_reaches_response_stream( assert submitted_data["attachments"][0]["type"] == "image" -def test_button_only_human_input_reaches_response_stream(app: Flask, sqlite_session_factory: sessionmaker[Session]): +def test_button_only_human_input_reaches_response_stream( + app: Flask, + sqlite_session_factory: sessionmaker[Session], + *, + workflow_contexts: ExecutionContexts, + tool_providers: ToolProviders, +) -> None: events = _publish_node_events(_build_node("Approve deployment?", with_inputs=False)) - payloads = _sse_payloads(events, InvokeFrom.WEB_APP, app, sqlite_session_factory) + payloads = _sse_payloads( + events, + InvokeFrom.WEB_APP, + app, + sqlite_session_factory, + workflow_contexts=workflow_contexts, + tool_providers=tool_providers, + ) assert [payload["event"] for payload in payloads] == [ "node_started", @@ -1648,8 +1733,13 @@ def test_button_only_human_input_reaches_response_stream(app: Flask, sqlite_sess @pytest.mark.parametrize("status", [HumanInputFormStatus.TIMEOUT, HumanInputFormStatus.WAITING]) def test_timed_out_human_input_reaches_response_stream( - status: HumanInputFormStatus, sqlite_session_factory: sessionmaker[Session], app: Flask -): + status: HumanInputFormStatus, + sqlite_session_factory: sessionmaker[Session], + app: Flask, + *, + workflow_contexts: ExecutionContexts, + tool_providers: ToolProviders, +) -> None: expiration_time = _human_input_form_filled_event_datetime.datetime(2025, 1, 1) for form_id, expiration in ( ("previous-execution", _human_input_form_filled_event_datetime.datetime(2024, 1, 1)), @@ -1664,9 +1754,16 @@ def test_timed_out_human_input_reaches_response_stream( status=status, ) - events = _publish_node_events(_build_timeout_node(expiration_time, status=status)) + events = _publish_node_events(_build_timeout_node(expiration_time, status=status), sessions=sqlite_session_factory) - payloads = _sse_payloads(events, InvokeFrom.WEB_APP, app, sqlite_session_factory) + payloads = _sse_payloads( + events, + InvokeFrom.WEB_APP, + app, + sqlite_session_factory, + workflow_contexts=workflow_contexts, + tool_providers=tool_providers, + ) assert [payload["event"] for payload in payloads] == [ "node_started", "human_input_form_timeout", @@ -1683,8 +1780,15 @@ def test_timed_out_human_input_reaches_response_stream( @pytest.mark.parametrize("timed_out", [False, True]) @pytest.mark.parametrize("terminal", ["end", "answer"]) def test_human_input_completion_and_referenced_answer_reach_response_stream( - timed_out: bool, terminal: str, mocker: MockerFixture, app: Flask, sqlite_session_factory: sessionmaker[Session] -): + timed_out: bool, + terminal: str, + mocker: MockerFixture, + app: Flask, + sqlite_session_factory: sessionmaker[Session], + *, + workflow_contexts: ExecutionContexts, + tool_providers: ToolProviders, +) -> None: expiration_time = _human_input_form_filled_event_datetime.datetime(2025, 1, 1) node = ( _build_timeout_node(expiration_time, node_id="human") @@ -1742,8 +1846,20 @@ def bind_form(execution_id: str) -> None: mocker.patch.object(node, "bind_execution_id", side_effect=bind_form) - events = _publish_graph_events(iter_dify_graph_engine_events(engine), graph=graph, runtime_state=runtime_state) - payloads = _sse_payloads(events, InvokeFrom.WEB_APP, app, sqlite_session_factory, runtime_state) + repository = HumanInputFormSubmissionRepository(sessions=sqlite_session_factory) + events = _publish_graph_events( + iter_dify_graph_engine_events(engine, human_form_reader=repository), + runtime_state=runtime_state, + ) + payloads = _sse_payloads( + events, + InvokeFrom.WEB_APP, + app, + sqlite_session_factory, + runtime_state, + workflow_contexts=workflow_contexts, + tool_providers=tool_providers, + ) form_event = "human_input_form_timeout" if timed_out else "human_input_form_filled" lifecycle_events = [payload for payload in payloads if payload["event"] != "message"] @@ -1768,7 +1884,7 @@ def bind_form(execution_id: str) -> None: @pytest.mark.parametrize("form_owner", [None, ("other-app", "node-1"), ("app", "other-node")]) def test_timeout_rejects_missing_or_unrelated_form( form_owner: tuple[str, str] | None, sqlite_session_factory: sessionmaker[Session] -): +) -> None: if form_owner is not None: app_id, node_id = form_owner _persist_form( @@ -1781,4 +1897,4 @@ def test_timeout_rejects_missing_or_unrelated_form( ) with pytest.raises(ValueError, match="Cannot resolve timed-out human input form"): - _publish_node_events(_build_timeout_node()) + _publish_node_events(_build_timeout_node(), sessions=sqlite_session_factory) diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_pause_reason.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_pause_reason.py index 2d68ec2c1920be..d9e3fa590c2404 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_pause_reason.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_pause_reason.py @@ -4,18 +4,18 @@ import pytest from pydantic import TypeAdapter +from sqlalchemy.orm import Session, sessionmaker from core.app.entities.task_entities import HumanInputRequiredResponse from core.entities.execution_extra_content import ( HumanInputContent, HumanInputFormDefinition, ) -from core.repositories.human_input_repository import HumanInputFormRecord from core.workflow.human_input_policy import FormDisposition, enrich_human_input_pause_reasons -from core.workflow.nodes.human_input.boundary import build_human_input_pause_reason, human_input_container_selector from core.workflow.nodes.human_input.pause_reason import DifyHITLEventType, HumanInputRequired from core.workflow.nodes.human_input.session_binding import SessionBinding from enums.human_input import ( + ApprovalChannel, ButtonStyle, HumanInputFormKind, HumanInputFormStatus, @@ -24,7 +24,7 @@ ) from graphon.entities.pause_reason import HitlRequired from graphon.runtime import RuntimeState, VariablePool -from models.human_input import HumanInputForm +from models.human_input_contracts import HumanInputFormRecord from models.human_input_entities import ( FormDefinition, FormInputConfig, @@ -32,20 +32,71 @@ SelectInputConfig, StringListSource, ) +from repositories.human_input.form_repository import HumanInputFormSubmissionRepository +from services.workflow.execution.adapters.human_input_events import ( + build_human_input_pause_reason, + enrich_graph_pause_reasons, + human_input_container_selector, +) _HUMAN_INPUT_REASON = {"TYPE": DifyHITLEventType.HUMAN_INPUT_REQUIRED, "form_id": "f1"} +def _form_record( + *, + form_id: str, + node_id: str, + definition: FormDefinition, + rendered_content: str, +) -> HumanInputFormRecord: + return HumanInputFormRecord( + form_id=form_id, + workflow_run_id="run", + conversation_id=None, + node_id=node_id, + tenant_id="tenant", + app_id="app", + form_kind=HumanInputFormKind.RUNTIME, + definition=definition, + rendered_content=rendered_content, + created_at=datetime(2026, 1, 1), + expiration_time=definition.expiration_time, + status=HumanInputFormStatus.WAITING, + selected_action_id=None, + submitted_data=None, + submitted_at=None, + submission_user_id=None, + submission_end_user_id=None, + completed_by_recipient_id=None, + recipient_id=None, + recipient_type=None, + access_token=None, + ) + + @pytest.mark.parametrize( ("dispositions", "expected_token", "expected_channels"), [ - ({"f1": FormDisposition(form_token=None, approval_channels=["console", "email"])}, None, ["console", "email"]), + ( + { + "f1": FormDisposition( + form_token=None, + approval_channels=[ApprovalChannel.CONSOLE, ApprovalChannel.EMAIL], + ) + }, + None, + [ApprovalChannel.CONSOLE, ApprovalChannel.EMAIL], + ), ({"f1": FormDisposition(form_token="tok", approval_channels=[])}, "tok", []), # form_id absent from the map (no recipient rows) falls back to no token, no channels. ({}, None, []), ], ) -def test_enrich_projects_disposition_onto_reason(dispositions, expected_token, expected_channels): +def test_enrich_projects_disposition_onto_reason( + dispositions: dict[str, FormDisposition], + expected_token: str | None, + expected_channels: list[ApprovalChannel], +) -> None: out = enrich_human_input_pause_reasons( [dict(_HUMAN_INPUT_REASON)], dispositions_by_form_id=dispositions, @@ -56,12 +107,12 @@ def test_enrich_projects_disposition_onto_reason(dispositions, expected_token, e assert out[0]["approval_channels"] == expected_channels -def test_enrich_leaves_non_human_input_reasons_untouched(): +def test_enrich_leaves_non_human_input_reasons_untouched() -> None: reason = {"TYPE": "something_else", "form_id": "f1"} out = enrich_human_input_pause_reasons( [reason], - dispositions_by_form_id={"f1": FormDisposition(form_token="tok", approval_channels=["email"])}, + dispositions_by_form_id={"f1": FormDisposition(form_token="tok", approval_channels=[ApprovalChannel.EMAIL])}, expiration_times_by_form_id={}, ) @@ -70,7 +121,20 @@ def test_enrich_leaves_non_human_input_reasons_untouched(): assert "approval_channels" not in out[0] -def test_pause_reason_payload_carries_approval_channels_through_factory(): +def test_enrich_graph_pause_reasons_raises_when_hitl_form_record_is_missing( + sqlite_session_factory: sessionmaker[Session], +) -> None: + form_repository = HumanInputFormSubmissionRepository(sessions=sqlite_session_factory) + + with pytest.raises(LookupError, match="form-123"): + enrich_graph_pause_reasons( + reasons=[HitlRequired(session_id="form-123", node_id="node-1", node_title="Ask Name")], + form_repository=form_repository, + variable_pool=None, + ) + + +def test_pause_reason_payload_carries_approval_channels_through_factory() -> None: # from_response_data maps fields by hand; this guards approval_channels/form_token # (the fields this feature added) against being dropped in that mapping. from core.app.entities.task_entities import ( @@ -93,7 +157,7 @@ def test_pause_reason_payload_carries_approval_channels_through_factory(): assert payload.form_token is None -def test_build_pause_reason_keeps_constant_options_from_child_form(): +def test_build_pause_reason_keeps_constant_options_from_child_form() -> None: definition = FormDefinition( form_content="Choose", rendered_content="Choose", @@ -106,21 +170,11 @@ def test_build_pause_reason_keeps_constant_options_from_child_form(): node_title="Choose", expiration_time=datetime(2026, 1, 2), ) - record = HumanInputFormRecord.from_models( - HumanInputForm( - id="form-123", - tenant_id="tenant", - app_id="app", - workflow_run_id="run", - node_id="human-input", - form_kind=HumanInputFormKind.RUNTIME, - form_definition=definition.model_dump_json(), - rendered_content=definition.rendered_content, - created_at=datetime(2026, 1, 1), - expiration_time=definition.expiration_time, - status=HumanInputFormStatus.WAITING, - ), - None, + record = _form_record( + form_id="form-123", + node_id="human-input", + definition=definition, + rendered_content=definition.rendered_content, ) parent_pool = VariablePool() parent_pool.add(("start", "options"), ["wrong"]) @@ -136,7 +190,7 @@ def test_build_pause_reason_keeps_constant_options_from_child_form(): assert reason.inputs[0].option_source.value == [] -def test_child_forms_keep_distinct_identity_when_projected_to_the_same_visible_tool(): +def test_child_forms_keep_distinct_identity_when_projected_to_the_same_visible_tool() -> None: definition = FormDefinition( form_content="Approval", rendered_content="Approval", @@ -155,21 +209,11 @@ def test_child_forms_keep_distinct_identity_when_projected_to_the_same_visible_t enriched = [ build_human_input_pause_reason( reason=reason, - record=HumanInputFormRecord.from_models( - HumanInputForm( - id=reason.session_id, - tenant_id="tenant", - app_id="app", - workflow_run_id="run", - node_id="source-human", - form_kind=HumanInputFormKind.RUNTIME, - form_definition=definition.model_dump_json(), - rendered_content=reason.session_id, - created_at=datetime(2026, 1, 1), - expiration_time=definition.expiration_time, - status=HumanInputFormStatus.WAITING, - ), - None, + record=_form_record( + form_id=reason.session_id, + node_id="source-human", + definition=definition, + rendered_content=reason.session_id, ), variable_pool=restored.variable_pool, ) diff --git a/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py b/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py index c016fb269fb6b2..0c54efb7ae2121 100644 --- a/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/knowledge_retrieval/test_knowledge_retrieval_node.py @@ -1,6 +1,5 @@ import time import uuid -from unittest.mock import Mock import pytest from pytest_mock import MockerFixture @@ -16,17 +15,17 @@ SingleRetrievalConfig, ) from core.workflow.nodes.knowledge_retrieval.exc import RateLimitExceededError -from core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node import ( - KnowledgeRetrievalNode, - _normalize_metadata_filter_scalar, - _normalize_metadata_filter_sequence_item, -) from core.workflow.nodes.knowledge_retrieval.retrieval import Source, SourceMetadata from core.workflow.system_variables import build_system_variables from graphon.enums import WorkflowNodeExecutionStatus from graphon.model_runtime.entities.llm_entities import LLMUsage from graphon.runtime import RuntimeState, VariablePool from graphon.variables import ArrayObjectSegment, StringSegment +from services.workflow.execution.adapters.knowledge_retrieval import ( + KnowledgeRetrievalNode, + _normalize_metadata_filter_scalar, + _normalize_metadata_filter_sequence_item, +) from tests.workflow_test_utils import build_test_graph_init_params @@ -76,10 +75,6 @@ def mock_rag_retrieval(mocker: MockerFixture): """Keep real usage and request metadata while isolating retrieval I/O.""" mock_retrieval = DatasetRetrieval() mocker.patch.object(mock_retrieval, "knowledge_retrieval", return_value=[]) - mocker.patch( - "core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node.DatasetRetrieval", - return_value=mock_retrieval, - ) return mock_retrieval @@ -134,6 +129,7 @@ def test_run_with_no_query_or_attachment( } node = KnowledgeRetrievalNode( + retrieval=mock_rag_retrieval, node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), init_params=mock_graph_init_params, @@ -191,6 +187,7 @@ def test_run_with_query_variable_single_mode( mock_rag_retrieval.knowledge_retrieval.return_value = [mock_source] node = KnowledgeRetrievalNode( + retrieval=mock_rag_retrieval, node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), init_params=mock_graph_init_params, @@ -233,6 +230,7 @@ def test_run_with_query_variable_multiple_mode( mock_rag_retrieval.knowledge_retrieval.return_value = [mock_source] node = KnowledgeRetrievalNode( + retrieval=mock_rag_retrieval, node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), init_params=mock_graph_init_params, @@ -269,6 +267,7 @@ def test_run_with_invalid_query_variable_type( } node = KnowledgeRetrievalNode( + retrieval=mock_rag_retrieval, node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), init_params=mock_graph_init_params, @@ -304,6 +303,7 @@ def test_run_with_invalid_attachment_variable_type( } node = KnowledgeRetrievalNode( + retrieval=mock_rag_retrieval, node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), init_params=mock_graph_init_params, @@ -344,6 +344,7 @@ def test_run_with_rate_limit_exceeded( ) node = KnowledgeRetrievalNode( + retrieval=mock_rag_retrieval, node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), init_params=mock_graph_init_params, @@ -382,6 +383,7 @@ def test_run_with_generic_exception( mock_rag_retrieval.knowledge_retrieval.side_effect = Exception("Unexpected error") node = KnowledgeRetrievalNode( + retrieval=mock_rag_retrieval, node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), init_params=mock_graph_init_params, @@ -462,6 +464,7 @@ def test_fetch_dataset_retriever_single_mode( config = {"id": node_id, "data": node_data.model_dump()} node = KnowledgeRetrievalNode( + retrieval=mock_rag_retrieval, node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), init_params=mock_graph_init_params, @@ -469,7 +472,7 @@ def test_fetch_dataset_retriever_single_mode( ) # Act - results, usage = node._fetch_dataset_retriever(Mock(), node_data=node_data, variables=variables) + results, usage = node._fetch_dataset_retriever(node_data=node_data, variables=variables) # Assert assert len(results) == 1 @@ -498,6 +501,7 @@ def test_fetch_dataset_retriever_multiple_mode_with_reranking( } node = KnowledgeRetrievalNode( + retrieval=mock_rag_retrieval, node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), init_params=mock_graph_init_params, @@ -505,7 +509,7 @@ def test_fetch_dataset_retriever_multiple_mode_with_reranking( ) # Act - results, usage = node._fetch_dataset_retriever(Mock(), node_data=sample_node_data, variables=variables) + results, usage = node._fetch_dataset_retriever(node_data=sample_node_data, variables=variables) # Assert assert isinstance(results, list) @@ -552,6 +556,7 @@ def test_fetch_dataset_retriever_multiple_mode_without_reranking( } node = KnowledgeRetrievalNode( + retrieval=mock_rag_retrieval, node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), init_params=mock_graph_init_params, @@ -559,7 +564,7 @@ def test_fetch_dataset_retriever_multiple_mode_without_reranking( ) # Act - results, usage = node._fetch_dataset_retriever(Mock(), node_data=node_data, variables=variables) + results, usage = node._fetch_dataset_retriever(node_data=node_data, variables=variables) # Assert assert isinstance(results, list) @@ -592,6 +597,7 @@ def test_resolve_metadata_filtering_conditions_templates( mock_graph_runtime_state.variable_pool.add(["start", "query"], StringSegment(value="readme")) node = KnowledgeRetrievalNode( + retrieval=mock_rag_retrieval, node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), init_params=mock_graph_init_params, @@ -653,6 +659,7 @@ def test_fetch_passes_resolved_metadata_conditions( node_id = str(uuid.uuid4()) config = {"id": node_id, "data": node_data.model_dump()} node = KnowledgeRetrievalNode( + retrieval=mock_rag_retrieval, node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), init_params=mock_graph_init_params, @@ -662,7 +669,7 @@ def test_fetch_passes_resolved_metadata_conditions( mock_rag_retrieval.knowledge_retrieval.return_value = [] # Act - node._fetch_dataset_retriever(Mock(), node_data=node_data, variables=variables) + node._fetch_dataset_retriever(node_data=node_data, variables=variables) # Assert the passed request has resolved value call_args = mock_rag_retrieval.knowledge_retrieval.call_args diff --git a/api/tests/unit_tests/core/workflow/nodes/trigger_webhook/test_node.py b/api/tests/unit_tests/core/workflow/nodes/trigger_webhook/test_node.py index 4b45f2c0ecfb97..abee566623f7a9 100644 --- a/api/tests/unit_tests/core/workflow/nodes/trigger_webhook/test_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/trigger_webhook/test_node.py @@ -11,12 +11,12 @@ WebhookData, WebhookParameter, ) -from core.workflow.nodes.trigger_webhook.node import TriggerWebhookNode from core.workflow.system_variables import default_system_variables from graphon.enums import WorkflowNodeExecutionStatus from graphon.file import File, FileTransferMethod, FileType from graphon.runtime import InitParams, RuntimeState, VariablePool from graphon.variables import FileVariable, StringVariable +from services.workflow.execution.adapters.trigger_webhook import TriggerWebhookNode from tests.workflow_test_utils import build_test_variable_pool """ @@ -138,8 +138,8 @@ def test_webhook_node_file_conversion_to_file_variable(): # Mock the file reference boundary and variable factory with ( patch.object(node._file_reference_factory, "build_from_mapping") as mock_file_factory, - patch("core.workflow.nodes.trigger_webhook.node.build_segment_with_type") as mock_segment_factory, - patch("core.workflow.nodes.trigger_webhook.node.FileVariable") as mock_file_variable, + patch("services.workflow.execution.adapters.trigger_webhook.build_segment_with_type") as mock_segment_factory, + patch("services.workflow.execution.adapters.trigger_webhook.FileVariable") as mock_file_variable, ): # Setup mocks mock_file_obj = Mock() @@ -325,8 +325,8 @@ def test_webhook_node_file_conversion_mixed_parameters(): with ( patch.object(node._file_reference_factory, "build_from_mapping") as mock_file_factory, - patch("core.workflow.nodes.trigger_webhook.node.build_segment_with_type") as mock_segment_factory, - patch("core.workflow.nodes.trigger_webhook.node.FileVariable") as mock_file_variable, + patch("services.workflow.execution.adapters.trigger_webhook.build_segment_with_type") as mock_segment_factory, + patch("services.workflow.execution.adapters.trigger_webhook.FileVariable") as mock_file_variable, ): # Setup mocks for file mock_file_obj = Mock() @@ -391,8 +391,8 @@ def test_webhook_node_different_file_types(): with ( patch.object(node._file_reference_factory, "build_from_mapping") as mock_file_factory, - patch("core.workflow.nodes.trigger_webhook.node.build_segment_with_type") as mock_segment_factory, - patch("core.workflow.nodes.trigger_webhook.node.FileVariable") as mock_file_variable, + patch("services.workflow.execution.adapters.trigger_webhook.build_segment_with_type") as mock_segment_factory, + patch("services.workflow.execution.adapters.trigger_webhook.FileVariable") as mock_file_variable, ): # Setup mocks for all files mock_file_objs = [Mock() for _ in range(3)] diff --git a/api/tests/unit_tests/core/workflow/test_node_factory.py b/api/tests/unit_tests/core/workflow/test_node_factory.py index 9caeb262da4f42..bc645143ec3c23 100644 --- a/api/tests/unit_tests/core/workflow/test_node_factory.py +++ b/api/tests/unit_tests/core/workflow/test_node_factory.py @@ -17,11 +17,9 @@ from core.plugin.impl.model import PluginModelClient from core.plugin.impl.model_runtime import PluginModelRuntime from core.plugin.plugin_service import PluginService -from core.workflow import node_factory +from core.workflow import node_factory as core_node_factory from core.workflow import template_rendering as workflow_template_rendering from core.workflow.llm_node import DifyLLMNode -from core.workflow.node_factory import get_node_type_classes_mapping -from core.workflow.node_runtime import DifyPreparedLLM from core.workflow.nodes.knowledge_index import KNOWLEDGE_INDEX_NODE_TYPE from graphon.entities.base_node_data import BaseNodeData from graphon.enums import BuiltinNodeTypes, NodeExecutionType, NodeType @@ -46,7 +44,10 @@ from graphon.variables.segments import ArrayObjectSegment, ObjectSegment, StringSegment from models.base import TypeBase from models.model import AppMode, Conversation, ConversationFromSource +from services.workflow.execution.adapters import node_factory from services.workflow.execution.adapters.node_factory import DifyNodeFactory as ServiceDifyNodeFactory +from services.workflow.execution.adapters.node_factory import get_node_type_classes_mapping +from services.workflow.execution.adapters.node_runtime import DifyPreparedLLM from services.workflow.execution.ports import WorkflowRuntime from tests.helpers.unit.core.model_fixtures import make_model_instance from tests.workflow_test_utils import build_test_graph_init_params @@ -391,7 +392,7 @@ def memory_session_maker(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) TypeBase.metadata.create_all(sqlite_engine, tables=[Conversation.__table__]) session_maker = sessionmaker(sqlite_engine, expire_on_commit=False) - monkeypatch.setattr(node_factory.session_factory, "create_session", session_maker) + monkeypatch.setattr(core_node_factory.session_factory, "create_session", session_maker) return session_maker class TestResolveWorkflowNodeClass: @@ -440,7 +441,7 @@ class TestFetchMemory: ], ) def test_returns_none_when_memory_or_conversation_is_missing(self, conversation_id, memory_config): - result = node_factory.fetch_memory( + result = core_node_factory.fetch_memory( conversation_id=conversation_id, app_id="app-id", node_data_memory=memory_config, @@ -450,7 +451,7 @@ def test_returns_none_when_memory_or_conversation_is_missing(self, conversation_ assert result is None def test_returns_none_when_conversation_does_not_exist(self, memory_session_maker: sessionmaker[Session]): - result = node_factory.fetch_memory( + result = core_node_factory.fetch_memory( conversation_id="conversation-id", app_id="app-id", node_data_memory=object(), @@ -465,9 +466,9 @@ def test_builds_token_buffer_memory_for_existing_conversation( memory = sentinel.memory _persist_conversation(memory_session_maker) token_buffer_memory = MagicMock(return_value=memory) - monkeypatch.setattr(node_factory, "TokenBufferMemory", token_buffer_memory) + monkeypatch.setattr(core_node_factory, "TokenBufferMemory", token_buffer_memory) - result = node_factory.fetch_memory( + result = core_node_factory.fetch_memory( conversation_id="conversation-id", app_id="app-id", node_data_memory=object(), @@ -490,10 +491,10 @@ def engine(self): token_buffer_memory = MagicMock(return_value=sentinel.memory) _persist_conversation(memory_session_maker) - monkeypatch.setattr(node_factory, "db", RaisingDB(), raising=False) - monkeypatch.setattr(node_factory, "TokenBufferMemory", token_buffer_memory) + monkeypatch.setattr(core_node_factory, "db", RaisingDB(), raising=False) + monkeypatch.setattr(core_node_factory, "TokenBufferMemory", token_buffer_memory) - result = node_factory.fetch_memory( + result = core_node_factory.fetch_memory( conversation_id="conversation-id", app_id="app-id", node_data_memory=object(), @@ -677,8 +678,9 @@ def test_validates_mapping_context(self): class TestDifyNodeFactoryCreateNode: @pytest.fixture - def factory(self): + def factory(self, workflow_runtime): factory = object.__new__(node_factory.DifyNodeFactory) + factory._workflow_runtime = workflow_runtime factory.init_params = sentinel.init_params factory.runtime_state = SimpleNamespace(variable_pool=MagicMock()) factory._dify_context = SimpleNamespace( @@ -1529,9 +1531,10 @@ def test_propagates_fetch_model_config_errors(self, monkeypatch: pytest.MonkeyPa class TestDifyNodeFactoryMemory: @pytest.fixture - def factory(self): + def factory(self, workflow_runtime): factory = object.__new__(node_factory.DifyNodeFactory) - factory._dify_context = SimpleNamespace(app_id="app-id") + factory._dify_context = SimpleNamespace(tenant_id="tenant-id", app_id="app-id") + factory._workflow_runtime = workflow_runtime factory.runtime_state = SimpleNamespace(variable_pool=MagicMock()) return factory @@ -1544,41 +1547,27 @@ def test_returns_none_when_memory_is_not_configured(self, factory): assert result is None factory.runtime_state.variable_pool.get.assert_not_called() - def test_uses_string_segment_conversation_id(self, monkeypatch: pytest.MonkeyPatch, factory): - memory_config = sentinel.memory_config + def test_uses_string_segment_conversation_id(self, factory): factory.runtime_state.variable_pool.get.return_value = StringSegment(value="conversation-id") - fetch_memory = MagicMock(return_value=sentinel.memory) - monkeypatch.setattr(node_factory, "fetch_memory", fetch_memory) result = factory._build_memory_for_llm_node( - node_data=SimpleNamespace(memory=memory_config), + node_data=SimpleNamespace(memory=sentinel.memory_config), model_instance=sentinel.model_instance, ) - assert result is sentinel.memory + assert isinstance(result, node_factory.NodeMemory) + assert result.history is factory._workflow_runtime.history + assert result.tenant_id == "tenant-id" + assert result.app_id == "app-id" + assert result.conversation_id == "conversation-id" + assert result.model_instance is sentinel.model_instance factory.runtime_state.variable_pool.get.assert_called_once_with(("sys", "conversation_id")) - fetch_memory.assert_called_once_with( - conversation_id="conversation-id", - app_id="app-id", - node_data_memory=memory_config, - model_instance=sentinel.model_instance, - ) - def test_ignores_non_string_segment_conversation_ids(self, monkeypatch: pytest.MonkeyPatch, factory): - memory_config = sentinel.memory_config + def test_ignores_non_string_segment_conversation_ids(self, factory): factory.runtime_state.variable_pool.get.return_value = sentinel.segment - fetch_memory = MagicMock(return_value=sentinel.memory) - monkeypatch.setattr(node_factory, "fetch_memory", fetch_memory) - result = factory._build_memory_for_llm_node( - node_data=SimpleNamespace(memory=memory_config), - model_instance=sentinel.model_instance, - ) - - assert result is sentinel.memory - fetch_memory.assert_called_once_with( - conversation_id=None, - app_id="app-id", - node_data_memory=memory_config, + node_data=SimpleNamespace(memory=sentinel.memory_config), model_instance=sentinel.model_instance, ) + assert result is None + factory.runtime_state.variable_pool.get.assert_called_once_with(("sys", "conversation_id")) diff --git a/api/tests/unit_tests/core/workflow/test_node_runtime.py b/api/tests/unit_tests/core/workflow/test_node_runtime.py index 705d15ae65e378..895b77eeddfac9 100644 --- a/api/tests/unit_tests/core/workflow/test_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/test_node_runtime.py @@ -7,11 +7,12 @@ import types import uuid from collections.abc import Generator, Iterator, Mapping +from dataclasses import replace from datetime import UTC, datetime from decimal import Decimal from types import SimpleNamespace -from typing import override -from unittest.mock import MagicMock, Mock, patch, sentinel +from typing import cast, override +from unittest.mock import MagicMock, Mock, create_autospec, patch, sentinel from uuid import uuid4 import pytest @@ -33,28 +34,14 @@ from core.tools.entities.tool_entities import ToolInvokeMessage from core.tools.entities.tool_entities import ToolProviderType as CoreToolProviderType from core.tools.errors import ToolInvokeError -from core.tools.tool_engine import ToolEngine -from core.tools.tool_manager import ToolManager from core.tools.utils.configuration import ToolParameterConfigurationManager from core.tools.utils.message_transformer import ToolFileMessageTransformer -from core.workflow import node_runtime +from core.workflow import node_runtime as core_node_runtime from core.workflow.file_reference import parse_file_reference -from core.workflow.node_factory import DifyNodeFactory -from core.workflow.node_runtime import ( - DifyFileReferenceFactory, - DifyHumanInputNodeRuntime, - DifyPreparedLLM, - DifyPreparedPollingLLM, - DifyPromptMessageSerializer, - DifyRetrieverAttachmentLoader, - DifyToolFileManager, - DifyToolNodeRuntime, - apply_dify_debug_email_recipient, - build_dify_llm_file_saver, - resolve_dify_run_context, -) +from core.workflow.node_runtime import DifyRetrieverAttachmentLoader from core.workflow.system_variables import build_system_variables from enums.human_input import DeliveryMethodType +from extensions.application_services.workflow import WorkflowExecutionDependencies from extensions.ext_database import db from graphon.enums import WorkflowNodeExecutionStatus from graphon.file import File, FileTransferMethod, FileType @@ -87,12 +74,33 @@ from models.base import TypeBase from models.dataset import SegmentAttachmentBinding from models.enums import CreatorUserRole -from models.human_input_delivery import EmailDeliveryConfig, EmailDeliveryMethod, EmailRecipients -from models.human_input_delivery import InteractiveSurfaceDeliveryMethod as WebAppDeliveryMethod -from models.human_input_delivery import _InteractiveSurfaceDeliveryConfig as _WebAppDeliveryConfig +from models.human_input_delivery import ( + EmailDeliveryConfig, + EmailDeliveryMethod, + EmailRecipients, + InteractiveSurfaceDeliveryMethod, + _InteractiveSurfaceDeliveryConfig, +) from models.human_input_entities import FileInputConfig, FileListInputConfig, HumanInputNodeData from models.model import StorageType, UploadFile from models.tools import ToolFile +from services.tools.tool_engine import ToolEngine +from services.tools.tool_manager import ToolManager +from services.workflow.execution.adapters import node_runtime +from services.workflow.execution.adapters.node_factory import DifyNodeFactory +from services.workflow.execution.adapters.node_runtime import ( + DifyFileReferenceFactory, + DifyHumanInputNodeRuntime, + DifyPreparedLLM, + DifyPreparedPollingLLM, + DifyPromptMessageSerializer, + DifyToolFileManager, + DifyToolNodeRuntime, + apply_dify_debug_email_recipient, + build_dify_llm_file_saver, + resolve_dify_run_context, +) +from services.workflow.execution.ports import WorkflowToolInvoker from tests.helpers.unit.core.model_fixtures import make_model_config, make_model_instance from tests.helpers.unit.core.workflow.nodes.parameter_extractor.fixtures.model import get_mocked_fetch_model_instance from tests.workflow_test_utils import build_test_graph_init_params, build_test_run_context, build_test_variable_pool @@ -890,7 +898,7 @@ def test_chat_parameter_extractor_with_memory(monkeypatch: pytest.MonkeyPatch) - """Unit coverage for tool-node invocation with the tool boundary patched.""" -def init_tool_node(config: dict[str, object]) -> ToolNode: +def init_tool_node(config: dict[str, object], workflow_runtime: WorkflowExecutionDependencies) -> ToolNode: graph_config = { "edges": [ { @@ -929,6 +937,7 @@ def init_tool_node(config: dict[str, object]) -> ToolNode: node_factory = DifyNodeFactory( init_params=init_params, runtime_state=graph_runtime_state, + workflow_runtime=workflow_runtime, ) Graph.init(graph_config=graph_config, node_factory=node_factory, root_node_id="start") @@ -941,7 +950,7 @@ def init_tool_node(config: dict[str, object]) -> ToolNode: init_params=init_params, runtime_state=graph_runtime_state, tool_file_manager=tool_file_manager, - runtime=DifyToolNodeRuntime(init_params.run_context), + runtime=DifyToolNodeRuntime(init_params.run_context, workflow_runtime=workflow_runtime), ) node.bind_execution_id(str(uuid.uuid4())) return node @@ -949,7 +958,7 @@ def init_tool_node(config: dict[str, object]) -> ToolNode: class TestToolNodeInvocationBehaviorCases: @staticmethod - def test_tool_variable_invoke() -> None: + def test_tool_variable_invoke(workflow_runtime: WorkflowExecutionDependencies) -> None: node = init_tool_node( config={ "id": "1", @@ -965,7 +974,8 @@ def test_tool_variable_invoke() -> None: "tool_configurations": {}, "tool_parameters": {}, }, - } + }, + workflow_runtime=workflow_runtime, ) with patch.object( @@ -979,12 +989,14 @@ def test_tool_variable_invoke() -> None: result = node._run() for item in result: if isinstance(item, StreamCompletedEvent): - assert item.node_run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED + assert item.node_run_result.status == WorkflowNodeExecutionStatus.SUCCEEDED, ( + item.node_run_result.error + ) assert item.node_run_result.outputs is not None assert item.node_run_result.outputs.get("text") is not None @staticmethod - def test_tool_mixed_invoke() -> None: + def test_tool_mixed_invoke(workflow_runtime: WorkflowExecutionDependencies) -> None: node = init_tool_node( config={ "id": "1", @@ -1002,7 +1014,8 @@ def test_tool_mixed_invoke() -> None: }, "tool_parameters": {}, }, - } + }, + workflow_runtime=workflow_runtime, ) with patch.object( @@ -1038,7 +1051,9 @@ def _build_tool_node_data() -> ToolNodeData: class TestToolNodeRuntimeCases: @staticmethod @pytest.fixture - def runtime(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> DifyToolNodeRuntime: + def runtime( + monkeypatch: pytest.MonkeyPatch, workflow_runtime: WorkflowExecutionDependencies + ) -> DifyToolNodeRuntime: module_name = "core.ops.ops_trace_manager" if module_name not in sys.modules: ops_stub = types.ModuleType(module_name) @@ -1056,8 +1071,7 @@ def runtime(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> DifyToolN invoke_from="debugger", call_depth=0, ) - session_maker = sessionmaker(sqlite_engine, expire_on_commit=False) - return DifyToolNodeRuntime(init_params.run_context, session_maker=session_maker) + return DifyToolNodeRuntime(init_params.run_context, workflow_runtime=workflow_runtime) @staticmethod def test_invoke_creates_callback_and_converts_messages(runtime: DifyToolNodeRuntime) -> None: @@ -1070,10 +1084,14 @@ def test_invoke_creates_callback_and_converts_messages(runtime: DifyToolNodeRunt variables=build_system_variables(conversation_id="conversation-id") ) workflow_tool = MagicMock() + tool_invoker = create_autospec(WorkflowToolInvoker, instance=True, spec_set=True) + tool_invoker.return_value = (message for message in [core_message]) + assert runtime._workflow_runtime is not None + dependencies = cast(WorkflowExecutionDependencies, runtime._workflow_runtime) + runtime._workflow_runtime = replace(dependencies, tool_invoker=tool_invoker) with ( patch.object(ToolManager, "get_workflow_tool_runtime", return_value=workflow_tool), - patch.object(ToolEngine, "generic_invoke", return_value=iter([core_message])) as generic_invoke_mock, patch.object( ToolFileMessageTransformer, "transform_tool_invoke_messages", @@ -1101,10 +1119,10 @@ def test_invoke_creates_callback_and_converts_messages(runtime: DifyToolNodeRunt assert isinstance(graph_message.message, ToolRuntimeMessage.TextMessage) assert graph_message.message.text == "https://dify.ai" - callback = generic_invoke_mock.call_args.kwargs["workflow_tool_callback"] + callback = tool_invoker.call_args.kwargs["workflow_tool_callback"] assert isinstance(callback, DifyWorkflowCallbackHandler) - assert generic_invoke_mock.call_args.kwargs["session"] is not None - assert generic_invoke_mock.call_args.kwargs["conversation_id"] == "conversation-id" + assert "session" not in tool_invoker.call_args.kwargs + assert tool_invoker.call_args.kwargs["conversation_id"] == "conversation-id" transform_kwargs = transform_tool_messages.call_args.kwargs assert transform_kwargs["conversation_id"] == "conversation-id" @@ -1330,30 +1348,51 @@ def test_resolve_provider_icons_prefers_builtin_tool_icons(runtime: DifyToolNode name="search", declaration=SimpleNamespace(icon={"plugin": "icon"}), ) - builtin_tool = SimpleNamespace( - name="langgenius/tools/search", - icon={"builtin": "icon"}, - icon_dark={"builtin": "dark"}, + from core.tools.entities.tool_entities import ToolProviderEntityWithPlugin + from core.tools.plugin_tool.provider import PluginToolProviderController + + builtin_tool = PluginToolProviderController( + entity=ToolProviderEntityWithPlugin.model_validate( + { + "identity": { + "name": "langgenius/tools/search", + "author": "author", + "description": {"en_US": "Search"}, + "label": {"en_US": "Search"}, + "icon": "light.svg", + "icon_dark": "dark.svg", + }, + } + ), + plugin_id="langgenius/tools", + plugin_unique_identifier="langgenius/tools:1", + tenant_id="tenant-id", ) with ( - patch("core.workflow.node_runtime.PluginInstaller") as installer_cls, + patch("services.workflow.execution.adapters.node_runtime.PluginInstaller") as installer_cls, patch( - "core.workflow.node_runtime.BuiltinToolManageService.list_builtin_tools", return_value=[builtin_tool] + "services.workflow.execution.adapters.node_runtime.ToolManager.list_builtin_providers", + return_value=[builtin_tool], ), ): installer_cls.return_value.list_plugins.return_value = [plugin] icon, icon_dark = runtime.resolve_provider_icons(provider_name="langgenius/tools/search") - assert icon == {"builtin": "icon"} - assert icon_dark == {"builtin": "dark"} + assert isinstance(icon, str) + assert "light.svg" in icon + assert isinstance(icon_dark, str) + assert "dark.svg" in icon_dark @staticmethod def test_resolve_provider_icons_returns_default_when_provider_is_unknown(runtime: DifyToolNodeRuntime) -> None: with ( - patch("core.workflow.node_runtime.PluginInstaller") as installer_cls, - patch("core.workflow.node_runtime.BuiltinToolManageService.list_builtin_tools", return_value=[]), + patch("services.workflow.execution.adapters.node_runtime.PluginInstaller") as installer_cls, + patch( + "services.workflow.execution.adapters.node_runtime.ToolManager.list_builtin_providers", + return_value=[], + ), ): installer_cls.return_value.list_plugins.return_value = [] @@ -1533,7 +1572,7 @@ def test_apply_dify_debug_email_recipient_rewrites_debug_target() -> None: [ (False, _build_email_method(debug_mode=True)), (True, _build_email_method(debug_mode=False)), - (True, WebAppDeliveryMethod(enabled=True, config=_WebAppDeliveryConfig())), + (True, InteractiveSurfaceDeliveryMethod(enabled=True, config=_InteractiveSurfaceDeliveryConfig())), ], ) def test_apply_dify_debug_email_recipient_noops_when_override_is_not_needed( @@ -1927,7 +1966,7 @@ def test_dify_retriever_attachment_loader_builds_graph_files( ) -> None: _persist_attachment(attachment_session, segment_id="segment-id", upload_file_id="upload-file-id") build_from_mapping = MagicMock(return_value=sentinel.file) - monkeypatch.setattr(node_runtime, "db", SimpleNamespace(engine=attachment_session.get_bind())) + monkeypatch.setattr(core_node_runtime, "db", SimpleNamespace(engine=attachment_session.get_bind())) loader = DifyRetrieverAttachmentLoader( file_reference_factory=SimpleNamespace(build_from_mapping=build_from_mapping) ) @@ -1954,7 +1993,7 @@ def test_dify_retriever_attachment_loader_grants_upload_files_for_allowed_segmen _persist_attachment(attachment_session, segment_id=segment_id, upload_file_id=upload_file_id) engine = attachment_session.get_bind() assert engine is not None - monkeypatch.setattr(node_runtime, "db", SimpleNamespace(engine=engine)) + monkeypatch.setattr(core_node_runtime, "db", SimpleNamespace(engine=engine)) session_maker = sessionmaker(engine, expire_on_commit=False) monkeypatch.setattr(file_builders.session_factory, "create_session", session_maker) @@ -1990,7 +2029,7 @@ def test_dify_retriever_attachment_loader_rejects_granted_upload_file_from_anoth ) engine = attachment_session.get_bind() assert engine is not None - monkeypatch.setattr(node_runtime, "db", SimpleNamespace(engine=engine)) + monkeypatch.setattr(core_node_runtime, "db", SimpleNamespace(engine=engine)) monkeypatch.setattr( file_builders.session_factory, "create_session", sessionmaker(engine, expire_on_commit=False) ) @@ -2016,7 +2055,7 @@ def test_dify_retriever_attachment_loader_skips_ungranted_segment_for_end_user( build_from_mapping = MagicMock() engine = attachment_session.get_bind() assert engine is not None - monkeypatch.setattr(node_runtime, "db", SimpleNamespace(engine=engine)) + monkeypatch.setattr(core_node_runtime, "db", SimpleNamespace(engine=engine)) statement_count = 0 def count_statements(*_args, **_kwargs) -> None: @@ -2054,7 +2093,7 @@ def test_dify_retriever_attachment_loader_skips_segment_rejected_by_checker( segment_access_checker = MagicMock(return_value=False) engine = attachment_session.get_bind() assert engine is not None - monkeypatch.setattr(node_runtime, "db", SimpleNamespace(engine=engine)) + monkeypatch.setattr(core_node_runtime, "db", SimpleNamespace(engine=engine)) statement_count = 0 def count_statements(*_args, **_kwargs) -> None: @@ -2146,6 +2185,7 @@ def test_dify_tool_file_manager_delegates_file_generator_lookup(monkeypatch: pyt @staticmethod def test_dify_tool_node_runtime_injects_outer_workflow_run_id_for_workflow_tools( monkeypatch: pytest.MonkeyPatch, + workflow_runtime, ) -> None: runtime_tool = SimpleNamespace(runtime=SimpleNamespace(runtime_parameters={})) get_runtime = MagicMock(return_value=runtime_tool) @@ -2158,7 +2198,7 @@ def test_dify_tool_node_runtime_injects_outer_workflow_run_id_for_workflow_tools ), ) - runtime = node_runtime.DifyToolNodeRuntime(_build_run_context()) + runtime = node_runtime.DifyToolNodeRuntime(_build_run_context(), workflow_runtime=workflow_runtime) node_data = ToolNodeData( title="Workflow Tool Node", desc=None, @@ -2189,6 +2229,7 @@ def test_dify_tool_node_runtime_injects_outer_workflow_run_id_for_workflow_tools @staticmethod def test_dify_tool_node_runtime_stores_trace_session_id_for_workflow_tools( monkeypatch: pytest.MonkeyPatch, + workflow_runtime, ) -> None: runtime_tool = SimpleNamespace(runtime=SimpleNamespace(runtime_parameters={})) get_runtime = MagicMock(return_value=runtime_tool) @@ -2203,7 +2244,7 @@ def test_dify_tool_node_runtime_stores_trace_session_id_for_workflow_tools( run_context = _build_run_context() run_context[DIFY_RUN_CONTEXT_KEY].trace_session_id = "session-1" - runtime = node_runtime.DifyToolNodeRuntime(run_context) + runtime = node_runtime.DifyToolNodeRuntime(run_context, workflow_runtime=workflow_runtime) node_data = ToolNodeData( title="Workflow Tool Node", desc=None, @@ -2229,13 +2270,14 @@ def test_dify_tool_node_runtime_stores_trace_session_id_for_workflow_tools( @staticmethod def test_dify_tool_node_runtime_does_not_inject_outer_workflow_run_id_for_non_workflow_tools( monkeypatch: pytest.MonkeyPatch, + workflow_runtime, ) -> None: runtime_tool = SimpleNamespace(runtime=SimpleNamespace(runtime_parameters={})) get_runtime = MagicMock(return_value=runtime_tool) monkeypatch.setattr(node_runtime.ToolManager, "get_workflow_tool_runtime", get_runtime) monkeypatch.setattr(node_runtime, "get_system_text", lambda _pool, _key: None) - runtime = node_runtime.DifyToolNodeRuntime(_build_run_context()) + runtime = node_runtime.DifyToolNodeRuntime(_build_run_context(), workflow_runtime=workflow_runtime) node_data = ToolNodeData( title="Builtin Tool Node", desc=None, @@ -2255,8 +2297,6 @@ def test_dify_tool_node_runtime_does_not_inject_outer_workflow_run_id_for_non_wo ) assert handle.raw.tool is runtime_tool - assert runtime_tool.runtime.dify_run_context.tenant_id == "tenant-id" - assert runtime_tool.runtime.dify_run_context.app_id == "app-id" assert "outer_workflow_run_id" not in runtime_tool.runtime.runtime_parameters get_runtime.assert_called_once() @@ -2295,7 +2335,7 @@ def prepare_container_inputs( return {"amount": Decimal("1.25")}, [{"id": "file-id", "name": "input.txt"}] monkeypatch.setattr(tool, "prepare_container_inputs", prepare_container_inputs) - runtime = DifyToolNodeRuntime(_build_run_context()) + runtime = core_node_runtime.DifyToolNodeRuntime(_build_run_context()) payload = runtime.build_workflow_tool_container_payload( tool_runtime=ToolRuntimeHandle(raw=tool), @@ -2318,7 +2358,7 @@ def prepare_container_inputs( @staticmethod def test_dify_tool_node_runtime_rejects_non_workflow_tool_container_payload() -> None: - runtime = DifyToolNodeRuntime(_build_run_context()) + runtime = core_node_runtime.DifyToolNodeRuntime(_build_run_context()) with pytest.raises(ToolRuntimeResolutionError, match="resolved tool is not a Workflow Tool"): runtime.build_workflow_tool_container_payload( tool_runtime=ToolRuntimeHandle(raw=object()), @@ -2328,24 +2368,30 @@ def test_dify_tool_node_runtime_rejects_non_workflow_tool_container_payload() -> ) @staticmethod - def test_dify_human_input_runtime_builds_debug_repository(monkeypatch: pytest.MonkeyPatch) -> None: - repository = MagicMock() - repository_cls = MagicMock(return_value=repository) - monkeypatch.setattr(node_runtime, "HumanInputFormRepositoryImpl", repository_cls) + def test_dify_human_input_runtime_builds_debug_repository() -> None: + calls = [] + repository = object() + + def forms(**kwargs): + calls.append(kwargs) + return repository runtime = DifyHumanInputNodeRuntime( _build_run_context(), + forms=forms, workflow_execution_id_getter=lambda: "workflow-execution-id", ) assert runtime.build_form_repository() is repository - repository_cls.assert_called_once_with( - tenant_id="tenant-id", - app_id="app-id", - workflow_execution_id="workflow-execution-id", - invoke_source="debugger", - submission_actor_id="user-id", - ) + assert calls == [ + { + "tenant_id": "tenant-id", + "app_id": "app-id", + "workflow_execution_id": "workflow-execution-id", + "invoke_source": "debugger", + "submission_actor_id": "user-id", + } + ] @staticmethod def test_dify_tool_runtime_spec_prefers_tool_parameters_for_runtime_form_values() -> None: @@ -2389,14 +2435,14 @@ def test_dify_human_input_runtime_create_form_filters_debugger_delivery_methods( node_data = HumanInputNodeData( title="Human Input", delivery_methods=[ - WebAppDeliveryMethod(enabled=True, config=_WebAppDeliveryConfig()), + InteractiveSurfaceDeliveryMethod(enabled=True, config=_InteractiveSurfaceDeliveryConfig()), _build_email_method(debug_mode=True), ], ) runtime = DifyHumanInputNodeRuntime( _build_run_context(), workflow_execution_id_getter=lambda: "workflow-execution-id", - form_repository=repository, + forms=lambda **_kwargs: repository, ) result = runtime.create_form( @@ -2427,13 +2473,15 @@ def test_dify_human_input_runtime_create_form_tags_conversation_id_for_chatflow( repository.create_form.return_value = sentinel.form node_data = HumanInputNodeData( title="Human Input", - delivery_methods=[WebAppDeliveryMethod(enabled=True, config=_WebAppDeliveryConfig())], + delivery_methods=[ + InteractiveSurfaceDeliveryMethod(enabled=True, config=_InteractiveSurfaceDeliveryConfig()) + ], ) runtime = DifyHumanInputNodeRuntime( _build_run_context(), workflow_execution_id_getter=lambda: "workflow-execution-id", conversation_id_getter=lambda: "conversation-id", - form_repository=repository, + forms=lambda **_kwargs: repository, ) runtime.create_form( @@ -2454,13 +2502,13 @@ def test_dify_human_input_runtime_preserves_webapp_delivery_for_web_invocations( node_data = HumanInputNodeData( title="Human Input", delivery_methods=[ - WebAppDeliveryMethod(enabled=True, config=_WebAppDeliveryConfig()), + InteractiveSurfaceDeliveryMethod(enabled=True, config=_InteractiveSurfaceDeliveryConfig()), _build_email_method(debug_mode=True), ], ) runtime = DifyHumanInputNodeRuntime( _build_run_context(invoke_from=InvokeFrom.WEB_APP), - form_repository=repository, + forms=lambda **_kwargs: repository, ) runtime.create_form( @@ -2479,8 +2527,8 @@ def test_dify_human_input_runtime_preserves_webapp_delivery_for_web_invocations( assert params.delivery_methods[1].config.recipients.include_bound_group is True @staticmethod - def test_dify_human_input_runtime_restore_submitted_data_rehydrates_files() -> None: - runtime = DifyHumanInputNodeRuntime(_build_run_context()) + def test_dify_human_input_runtime_restore_submitted_data_rehydrates_files(workflow_runtime) -> None: + runtime = DifyHumanInputNodeRuntime(_build_run_context(), forms=workflow_runtime.human_forms) file_value = File( file_id="file-1", file_type=FileType.DOCUMENT, diff --git a/api/tests/unit_tests/core/workflow/test_workflow_entry.py b/api/tests/unit_tests/core/workflow/test_workflow_entry.py index ee13159d138eea..13fc0634e6fb13 100644 --- a/api/tests/unit_tests/core/workflow/test_workflow_entry.py +++ b/api/tests/unit_tests/core/workflow/test_workflow_entry.py @@ -14,18 +14,11 @@ from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from core.credit_usage import CreditUsageAppType from core.helper.code_executor.code_executor import CodeLanguage -from core.repositories.human_input_repository import ( - HumanInputFormRepository, - HumanInputFormRepositoryImpl, -) -from core.workflow import workflow_entry -from core.workflow.nodes.human_input.callback import DifyHITLCallback from core.workflow.system_variables import build_system_variables, default_system_variables from core.workflow.variable_prefixes import ( CONVERSATION_VARIABLE_NODE_ID, ENVIRONMENT_VARIABLE_NODE_ID, ) -from core.workflow.workflow_entry import WorkflowEntry, iter_dify_graph_engine_events from enums.human_input import HumanInputFormStatus from graphon.engine import Engine from graphon.engine.command import InMemoryChannel, RedisChannel @@ -61,8 +54,17 @@ from graphon.variables.variables import StringVariable from libs.datetime_utils import naive_utc_now from models.human_input import HumanInputForm +from models.human_input_contracts import HumanInputFormRepository from models.human_input_entities import HumanInputNodeData, UserActionConfig from models.workflow import Workflow, WorkflowType +from repositories.human_input.form_repository import ( + HumanInputFormRepositoryImpl, + HumanInputFormSubmissionRepository, +) +from services.workflow.execution.adapters import workflow_entry +from services.workflow.execution.adapters.human_input import DifyHITLCallback +from services.workflow.execution.adapters.workflow_entry import WorkflowEntry, iter_dify_graph_engine_events +from services.workflow.execution.ports import WorkflowRuntime from tests.helpers.unit.config_override import config_overrides_context from tests.helpers.unit.model_factories import make_workflow from tests.helpers.workflow.mock_config import MockConfig, MockConfigBuilder @@ -76,6 +78,7 @@ from tests.unit_tests.core.workflow.test_workflow_tool_container_handler import ( _workflow_tool_container_workflow_tool_source as _workflow_tool_source, ) +from tests.unit_tests.workflow_execution import NO_HUMAN_INPUT_FORMS from tests.workflow_test_utils import build_test_graph_init_params """Unit coverage for Dify's in-memory ResponseStreamFilter boundary behavior.""" @@ -134,7 +137,7 @@ def test_dify_response_stream_filter_handles_issue_170_shape() -> None: runtime_state=graph_runtime_state, command_channel=InMemoryChannel(), ) - events = list(iter_dify_graph_engine_events(engine)) + events = list(iter_dify_graph_engine_events(engine, human_form_reader=NO_HUMAN_INPUT_FORMS)) stream_chunk_events = [event for event in events if isinstance(event, NodeRunStreamChunkEvent)] success_events = [event for event in events if isinstance(event, GraphRunSucceededEvent)] @@ -265,14 +268,12 @@ class TestResponseStreamFilterPauseResumeCases: @staticmethod def test_if_else_human_input_pause_resume_answer_chunks_survive_resume( sqlite_session_factory: sessionmaker[Session], - mocker: MockerFixture, ) -> None: - mocker.patch( - "core.repositories.human_input_repository.session_factory.create_session", - side_effect=sqlite_session_factory, - ) repository = HumanInputFormRepositoryImpl( - tenant_id="tenant", app_id="app", workflow_execution_id=WORKFLOW_EXECUTION_ID + tenant_id="tenant", + app_id="app", + workflow_execution_id=WORKFLOW_EXECUTION_ID, + sessions=sqlite_session_factory, ) # ---- Phase 1: run to GraphRunPausedEvent ---- runtime_state_1 = _build_runtime_state() @@ -318,7 +319,13 @@ def test_if_else_human_input_pause_resume_answer_chunks_survive_resume( filter_2 = ResponseStreamFilter() filter_2.loads(response_filter_snapshot) - phase2_events = list(iter_dify_graph_engine_events(engine_2, filter_2)) + phase2_events = list( + iter_dify_graph_engine_events( + engine_2, + filter_2, + human_form_reader=HumanInputFormSubmissionRepository(sessions=sqlite_session_factory), + ) + ) assert any(isinstance(e, GraphRunSucceededEvent) for e in phase2_events) @@ -482,7 +489,9 @@ def test_nested_overrides_take_precedence_over_explicit_ancestors_in_either_orde ), ], ) - def test_single_step_loads_explicit_inputs_for_nodes_without_graphon_extractors(self, data, inputs, expected): + def test_single_step_loads_explicit_inputs_for_nodes_without_graphon_extractors( + self, data, inputs, expected, workflow_runtime: WorkflowRuntime + ): workflow = Workflow.new( tenant_id="tenant", app_id="app", @@ -504,7 +513,7 @@ def test_single_step_loads_explicit_inputs_for_nodes_without_graphon_extractors( user_id="user", user_inputs=inputs, variable_pool=variable_pool, - execution_driver=None, + workflow_runtime=workflow_runtime, ) variable = variable_pool.get(["source", "value"]) @@ -560,7 +569,7 @@ def test_mapping_user_inputs_to_variable_pool_with_system_variables(self): assert output_var is not None assert output_var.value == "system_user" - def test_single_step_run_injects_code_limits(self): + def test_single_step_run_injects_code_limits(self, workflow_runtime: WorkflowRuntime): """Ensure single-step CodeNode execution configures limits.""" # Arrange node_id = "code_node" @@ -607,7 +616,7 @@ def test_single_step_run_injects_code_limits(self): user_id="user", user_inputs={}, variable_pool=variable_pool, - execution_driver=None, + workflow_runtime=workflow_runtime, ) # Assert @@ -1034,6 +1043,7 @@ def _build_minimal_workflow_entry( call_depth=0, graph_runtime_state=graph_runtime_state, response_stream_filter=response_stream_filter, + human_form_reader=NO_HUMAN_INPUT_FORMS, ) @@ -1048,6 +1058,7 @@ def test_rejects_call_depth_above_limit(self): graph=sentinel.graph, call_depth=call_depth, graph_runtime_state=sentinel.graph_runtime_state, + human_form_reader=NO_HUMAN_INPUT_FORMS, ) def test_hidden_workflow_tool_nodes_count_toward_execution_limit(self): @@ -1059,6 +1070,7 @@ def test_hidden_workflow_tool_nodes_count_toward_execution_limit(self): graph=_outer_graph(tool), call_depth=0, graph_runtime_state=tool.runtime_state, + human_form_reader=NO_HUMAN_INPUT_FORMS, ) entry.workflow_tool_sources[source.workflow_id] = source events = list(entry.run()) @@ -1081,6 +1093,7 @@ def test_workflow_entry_defaults_to_fresh_response_stream_filter(self, monkeypat class TestWorkflowEntryRun: def test_run_swallows_generate_task_stopped_errors(self): entry = object.__new__(workflow_entry.WorkflowEntry) + entry._human_form_reader = NO_HUMAN_INPUT_FORMS entry.graph_engine = MagicMock() entry.graph_engine.run.side_effect = GenerateTaskStoppedError() entry._response_stream_filter = ResponseStreamFilter() @@ -1113,7 +1126,9 @@ def test_iter_dify_graph_engine_events_applies_response_stream_filter(self): return_value=iter([sentinel.filtered_event]), ) as filter_engine_events, ): - events = list(workflow_entry.iter_dify_graph_engine_events(graph_engine)) + events = list( + workflow_entry.iter_dify_graph_engine_events(graph_engine, human_form_reader=NO_HUMAN_INPUT_FORMS) + ) assert events == [sentinel.filtered_event] from_engine.assert_called_once_with(graph_engine) @@ -1126,6 +1141,7 @@ def test_iter_dify_graph_engine_events_applies_response_stream_filter(self): def test_run_delegates_to_dify_event_iterator(self): entry = object.__new__(workflow_entry.WorkflowEntry) + entry._human_form_reader = NO_HUMAN_INPUT_FORMS entry.graph_engine = sentinel.graph_engine entry._response_stream_filter = sentinel.response_stream_filter @@ -1138,11 +1154,12 @@ def test_run_delegates_to_dify_event_iterator(self): assert events == [sentinel.filtered_event] iter_dify_graph_engine_events.assert_called_once_with( - sentinel.graph_engine, sentinel.response_stream_filter + sentinel.graph_engine, sentinel.response_stream_filter, human_form_reader=NO_HUMAN_INPUT_FORMS ) def test_run_emits_failed_event_for_unexpected_errors(self): entry = object.__new__(workflow_entry.WorkflowEntry) + entry._human_form_reader = NO_HUMAN_INPUT_FORMS entry.graph_engine = MagicMock() entry.graph_engine.run.side_effect = RuntimeError("boom") entry._response_stream_filter = ResponseStreamFilter() @@ -1155,7 +1172,7 @@ def test_run_emits_failed_event_for_unexpected_errors(self): class TestWorkflowEntrySingleStepRun: @pytest.mark.parametrize("node_type", [BuiltinNodeTypes.LOOP, BuiltinNodeTypes.ITERATION]) - def test_rejects_container_nodes(self, node_type): + def test_rejects_container_nodes(self, node_type, workflow_runtime: WorkflowRuntime): workflow = _workflow() with ( @@ -1168,10 +1185,10 @@ def test_rejects_container_nodes(self, node_type): user_id="user-id", user_inputs={}, variable_pool=sentinel.variable_pool, - execution_driver=None, + workflow_runtime=workflow_runtime, ) - def test_preloads_constructor_variables_before_creating_memory_node(self): + def test_preloads_constructor_variables_before_creating_memory_node(self, workflow_runtime: WorkflowRuntime): class FakeLLMNode: id = "node-id" title = "Node Title" @@ -1233,14 +1250,16 @@ def _create_node(_node_config): user_inputs={}, variable_pool=variable_pool, variable_loader=variable_loader, - execution_driver=None, + workflow_runtime=workflow_runtime, ) assert node.id == "node-id" assert list(generator) == ["event"] variable_loader.load_variables.assert_called_once_with([["sys", "conversation_id"]]) - def test_uses_empty_mapping_when_selector_extraction_is_not_implemented(self): + def test_uses_empty_mapping_when_selector_extraction_is_not_implemented( + self, workflow_runtime: WorkflowRuntime + ): class FakeNode: id = "node-id" title = "Node Title" @@ -1286,7 +1305,7 @@ def extract_variable_selector_to_variable_mapping(**_kwargs): user_id="user-id", user_inputs={"question": "hello"}, variable_pool=sentinel.variable_pool, - execution_driver=None, + workflow_runtime=workflow_runtime, ) assert node.id == "node-id" @@ -1309,7 +1328,7 @@ def extract_variable_selector_to_variable_mapping(**_kwargs): tenant_id="tenant-id", ) - def test_skips_user_input_mapping_for_datasource_nodes(self): + def test_skips_user_input_mapping_for_datasource_nodes(self, workflow_runtime: WorkflowRuntime): class FakeDatasourceNode: id = "node-id" node_type = "datasource" @@ -1354,7 +1373,7 @@ def extract_variable_selector_to_variable_mapping(**_kwargs): user_id="user-id", user_inputs={"question": "hello"}, variable_pool=sentinel.variable_pool, - execution_driver=None, + workflow_runtime=workflow_runtime, ) assert node.id == "node-id" @@ -1367,7 +1386,7 @@ def extract_variable_selector_to_variable_mapping(**_kwargs): ) mapping_user_inputs_to_variable_pool.assert_not_called() - def test_wraps_layered_node_run_failures(self): + def test_wraps_layered_node_run_failures(self, workflow_runtime: WorkflowRuntime): class FakeNode: id = "node-id" title = "Node Title" @@ -1411,7 +1430,7 @@ def version(): user_id="user-id", user_inputs={}, variable_pool=sentinel.variable_pool, - execution_driver=None, + workflow_runtime=workflow_runtime, ) class TestWorkflowEntryHelpers: @@ -1802,7 +1821,9 @@ def test_workflow_entry_uses_provided_redis_channel(self): redis_channel = RedisChannel(mock_redis_client, "test:channel:key") # Patch Engine to verify it receives the Redis channel - with patch("core.workflow.workflow_entry.Engine", autospec=True) as mock_graph_engine_type: + with patch( + "services.workflow.execution.adapters.workflow_entry.Engine", autospec=True + ) as mock_graph_engine_type: # Create WorkflowEntry with Redis channel workflow_entry = WorkflowEntry( graph_config=mock_graph_config, @@ -1810,6 +1831,7 @@ def test_workflow_entry_uses_provided_redis_channel(self): call_depth=0, graph_runtime_state=graph_runtime_state, command_channel=redis_channel, # Provide Redis channel + human_form_reader=NO_HUMAN_INPUT_FORMS, ) # Verify Engine was initialized with the Redis channel @@ -1828,8 +1850,12 @@ def test_workflow_entry_defaults_to_inmemory_channel(self): # Patch Engine and InMemoryChannel with ( - patch("core.workflow.workflow_entry.Engine", autospec=True) as mock_graph_engine_type, - patch("core.workflow.workflow_entry.InMemoryChannel", autospec=True) as mock_in_memory_channel, + patch( + "services.workflow.execution.adapters.workflow_entry.Engine", autospec=True + ) as mock_graph_engine_type, + patch( + "services.workflow.execution.adapters.workflow_entry.InMemoryChannel", autospec=True + ) as mock_in_memory_channel, ): mock_inmemory_channel = mock_in_memory_channel.return_value # Create WorkflowEntry without providing a channel @@ -1839,6 +1865,7 @@ def test_workflow_entry_defaults_to_inmemory_channel(self): call_depth=0, graph_runtime_state=graph_runtime_state, command_channel=None, # No channel provided + human_form_reader=NO_HUMAN_INPUT_FORMS, ) # Verify InMemoryChannel was created @@ -1867,7 +1894,9 @@ def test_workflow_entry_run_with_redis_channel(self): mock_event2 = MagicMock() # Patch Engine - with patch("core.workflow.workflow_entry.Engine", autospec=True) as mock_graph_engine_type: + with patch( + "services.workflow.execution.adapters.workflow_entry.Engine", autospec=True + ) as mock_graph_engine_type: mock_graph_engine = MagicMock() mock_graph_engine.run.return_value = iter([mock_event1, mock_event2]) mock_graph_engine_type.return_value = mock_graph_engine @@ -1879,6 +1908,7 @@ def test_workflow_entry_run_with_redis_channel(self): call_depth=0, graph_runtime_state=graph_runtime_state, command_channel=redis_channel, + human_form_reader=NO_HUMAN_INPUT_FORMS, ) # Run the workflow