From e2d48b44849cade99329d5e69f4ba6ec48f08075 Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Thu, 8 Oct 2026 18:34:55 +0800 Subject: [PATCH 1/5] test(api): migrate graph and node coverage to injected runtime adapters --- .../graph_engine/test_mock_factory.py | 2 +- .../workflow/graph_engine/test_mock_nodes.py | 6 +- .../test_parallel_human_input_join_resume.py | 21 +- .../graph_engine/test_table_runner.py | 8 +- .../graph_engine/test_tool_in_chatflow.py | 7 +- .../nodes/agent/test_agent_entities.py | 2 +- .../workflow/nodes/agent/test_agent_node.py | 2 +- .../nodes/agent/test_runtime_support.py | 4 +- .../nodes/agent_v2/test_ask_human_hitl.py | 9 +- .../nodes/agent_v2/test_dify_tools_builder.py | 230 +++++++----- .../agent_v2/test_runtime_request_builder.py | 241 ++++++++---- .../nodes/agent_v2/test_validators.py | 344 +++++++++--------- .../workflow/nodes/base/test_base_node.py | 2 +- .../test_get_node_type_classes_mapping.py | 2 +- .../datasource/test_datasource_node_stream.py | 19 +- .../http_request/test_http_request_node.py | 2 +- .../nodes/human_input/test_entities.py | 60 ++- .../test_human_input_form_filled_event.py | 118 +++--- .../test_knowledge_retrieval_node.py | 32 +- ..._parameter_extractor_execution_behavior.py | 2 +- .../test_template_transform_execution.py | 2 +- .../core/workflow/nodes/test_base_node.py | 2 +- .../core/workflow/nodes/test_if_else.py | 2 +- .../nodes/tool/test_tool_node_runtime.py | 58 ++- .../webhook/test_webhook_file_conversion.py | 14 +- .../nodes/webhook/test_webhook_node.py | 2 +- .../workflow/test_enrich_pause_reasons.py | 4 +- .../test_form_input_serialization_compat.py | 6 +- .../core/workflow/test_human_input_adapter.py | 14 +- .../workflow/test_human_input_callback.py | 4 +- .../core/workflow/test_node_factory.py | 146 +------- .../core/workflow/test_node_runtime.py | 314 +++------------- .../test_response_stream_filter_issue_170.py | 6 +- ...est_response_stream_filter_pause_resume.py | 16 +- .../core/workflow/test_workflow_entry.py | 2 +- .../workflow/test_workflow_entry_helpers.py | 17 +- .../test_workflow_entry_redis_channel.py | 17 +- 37 files changed, 815 insertions(+), 924 deletions(-) diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py index 9c507bb1619608..3309d1c77e2437 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_factory.py @@ -8,10 +8,10 @@ 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.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 .test_mock_nodes import ( MockAgentNode, diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py index 67b94e46399e24..049488b3e8110f 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_mock_nodes.py @@ -11,9 +11,6 @@ from unittest.mock import MagicMock from core.model_manager import ModelInstance -from core.workflow.node_runtime import DifyToolNodeRuntime -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 @@ -30,6 +27,9 @@ 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 DifyToolNodeRuntime if TYPE_CHECKING: from graphon.entities import GraphInitParams diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py index 8a6b82502ac4be..2110fd74a53c34 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_parallel_human_input_join_resume.py @@ -4,15 +4,6 @@ from datetime import datetime, timedelta from typing import Any, Protocol -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.entities import WorkflowStartReason @@ -34,6 +25,7 @@ from graphon.nodes.start.start_node import StartNode from graphon.runtime import GraphRuntimeState, VariablePool from libs.datetime_utils import naive_utc_now +from models.human_input_contracts import FormCreateParams, HumanInputFormEntity, HumanInputFormRepository from models.human_input_entities import ( FileInputConfig, FileListInputConfig, @@ -42,6 +34,9 @@ StringListSource, UserActionConfig, ) +from services.workflow.execution.adapters.human_input import ( + DifyHITLCallback, +) from tests.workflow_test_utils import build_test_graph_init_params @@ -93,10 +88,6 @@ class StaticForm(HumanInputFormEntity): def id(self) -> str: return self.form_id - @property - def submission_token(self) -> str | None: - return "token" - @property def recipients(self) -> list: return [] @@ -194,8 +185,6 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor ) 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, @@ -210,8 +199,6 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor ) 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, diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py b/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py index 175b5da8a1be92..68134270da88c9 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_table_runner.py @@ -1,3 +1,5 @@ +from tests.unit_tests.workflow_execution import NO_HUMAN_INPUT_FORMS + """ Table-driven test framework for GraphEngine workflows. @@ -21,10 +23,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.entities import GraphInitParams from graphon.graph import Graph from graphon.graph_engine import GraphEngine, GraphEngineConfig @@ -44,6 +44,8 @@ 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 .test_mock_config import MockConfig from .test_mock_factory import MockNodeFactory @@ -336,7 +338,7 @@ def _execute_test_case(self, test_case: WorkflowTestCase) -> WorkflowTestResult: # Execute and collect events events: list[GraphEngineEvent] = [] - 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 diff --git a/api/tests/unit_tests/core/workflow/graph_engine/test_tool_in_chatflow.py b/api/tests/unit_tests/core/workflow/graph_engine/test_tool_in_chatflow.py index 33e8f869a5aac1..3085580ad4dccb 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/test_tool_in_chatflow.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/test_tool_in_chatflow.py @@ -1,10 +1,11 @@ -from core.workflow.workflow_entry import iter_dify_graph_engine_events from graphon.graph_engine import GraphEngine, GraphEngineConfig from graphon.graph_engine.command_channels import InMemoryChannel from graphon.graph_events import ( GraphRunSucceededEvent, NodeRunStreamChunkEvent, ) +from services.workflow.execution.adapters.workflow_entry import iter_dify_graph_engine_events +from tests.unit_tests.workflow_execution import NO_HUMAN_INPUT_FORMS from .test_mock_config import MockConfigBuilder from .test_table_runner import TableTestRunner @@ -32,7 +33,7 @@ def test_tool_in_chatflow(): config=GraphEngineConfig(), ) - 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)] @@ -86,7 +87,7 @@ def test_answer_can_render_llm_structured_output_in_chatflow(): config=GraphEngineConfig(), ) - 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/unit_tests/core/workflow/nodes/agent/test_agent_entities.py b/api/tests/unit_tests/core/workflow/nodes/agent/test_agent_entities.py index e94a728b68c36a..b04cee4955f3c0 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent/test_agent_entities.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent/test_agent_entities.py @@ -1,11 +1,11 @@ from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, build_dify_run_context -from core.workflow.node_factory import DifyNodeFactory from graphon.entities import GraphInitParams from graphon.graph import Graph from graphon.graph_engine import GraphEngine, GraphEngineConfig from graphon.graph_engine.command_channels import InMemoryChannel from graphon.graph_events import GraphNodeEventBase, GraphRunSucceededEvent from graphon.runtime import GraphRuntimeState, 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_agent_node.py b/api/tests/unit_tests/core/workflow/nodes/agent/test_agent_node.py index b1596fecd1d14f..9991c7544234cb 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,6 +1,5 @@ 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.entities import GraphInitParams @@ -8,6 +7,7 @@ from graphon.graph_events import NodeRunStreamChunkEvent from graphon.node_events import StreamChunkEvent from graphon.runtime import GraphRuntimeState, 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_runtime_support.py b/api/tests/unit_tests/core/workflow/nodes/agent/test_runtime_support.py index f79f428264933a..0e6a7cf07497db 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 @@ -1,7 +1,6 @@ from types import SimpleNamespace from unittest.mock import Mock, patch -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, @@ -12,6 +11,7 @@ ParameterRule, ParameterType, ) +from services.workflow.execution.adapters.agent_runtime import AgentRuntimeSupport def test_fetch_model_reuses_single_model_assembly(): @@ -33,7 +33,7 @@ def test_fetch_model_reuses_single_model_assembly(): assembly.model_manager.get_model_instance.return_value = model_instance 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: resolved_instance, resolved_schema = AgentRuntimeSupport().fetch_model( diff --git a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_hitl.py b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_hitl.py index 488b9350cc2452..1195213c8fd7b5 100644 --- a/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_hitl.py +++ b/api/tests/unit_tests/core/workflow/nodes/agent_v2/test_ask_human_hitl.py @@ -9,7 +9,6 @@ from dify_agent.layers.ask_human import AskHumanToolArgs from dify_agent.protocol import DeferredToolCallPayload -from core.repositories.human_input_repository import FormCreateParams, HumanInputFormRepository from core.workflow.nodes.agent_v2.ask_human_hitl import ( AskHumanFormBuildError, ask_human_args_to_node_data, @@ -20,8 +19,14 @@ from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from enums.human_input import ButtonStyle, TimeoutUnit from models.agent_config_entities import AgentHumanContactConfig +from models.human_input_contracts import FormCreateParams, HumanInputFormRepository from models.human_input_delivery import EmailDeliveryMethod, ExternalRecipient, InteractiveSurfaceDeliveryMethod -from models.human_input_entities import FileInputConfig, FileListInputConfig, ParagraphInputConfig, SelectInputConfig +from models.human_input_entities import ( + FileInputConfig, + FileListInputConfig, + ParagraphInputConfig, + SelectInputConfig, +) def _args(**overrides: Any) -> AskHumanToolArgs: 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 87ce8ae065c0f3..0d58610ea15afd 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 +from unittest.mock import Mock import pytest @@ -18,12 +19,13 @@ ToolParameter, ToolProviderType, ) -from core.tools.tool_manager import ToolManager -from core.workflow.nodes.agent_v2.dify_tools_builder import ( +from models.agent_config_entities import AgentSoulToolsConfig +from models.tool_runtime_contracts import WorkflowToolQueries +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 AgentSoulToolsConfig class FakeRuntimeProvider: @@ -48,6 +50,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 @@ -227,9 +232,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=Mock(spec=WorkflowToolQueries), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -262,9 +271,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=Mock(spec=WorkflowToolQueries), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -288,9 +301,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=Mock(spec=WorkflowToolQueries), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -323,9 +340,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=Mock(spec=WorkflowToolQueries), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -354,9 +375,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=Mock(spec=WorkflowToolQueries), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -380,9 +405,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=Mock(spec=WorkflowToolQueries), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -420,9 +449,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=Mock(spec=WorkflowToolQueries), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -466,9 +499,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=Mock(spec=WorkflowToolQueries), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -497,40 +534,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", - ) - 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, + tool_providers=tool_providers, + workflow_queries=Mock(spec=WorkflowToolQueries), ) - - 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": [ @@ -555,9 +565,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=Mock(spec=WorkflowToolQueries), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -584,9 +598,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=Mock(spec=WorkflowToolQueries), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -615,9 +633,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=Mock(spec=WorkflowToolQueries), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -648,8 +670,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=Mock(spec=WorkflowToolQueries), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -675,7 +701,7 @@ def test_rejects_missing_required_runtime_parameter(): # ────────────────────────────────────────────────────────────────────────────── -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 @@ -684,7 +710,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=Mock(spec=WorkflowToolQueries), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -710,9 +740,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=Mock(spec=WorkflowToolQueries), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -735,11 +769,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=Mock(spec=WorkflowToolQueries), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -765,7 +803,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).""" @@ -776,7 +814,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=Mock(spec=WorkflowToolQueries), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -819,22 +861,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=Mock(spec=WorkflowToolQueries), ) 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=Mock(spec=WorkflowToolQueries), ) with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info: _build(builder, _standard_tools_payload()) @@ -842,12 +888,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=Mock(spec=WorkflowToolQueries), + ) with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info: _build(builder, _standard_tools_payload()) assert exc_info.value.error_code == "agent_tool_config_invalid" @@ -858,7 +908,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 @@ -871,7 +921,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=Mock(spec=WorkflowToolQueries), + ) with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info: _build(builder, _standard_tools_payload()) assert exc_info.value.error_code == "agent_tool_credential_shape_invalid" @@ -971,7 +1025,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]] = [] @@ -979,7 +1033,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=Mock(spec=WorkflowToolQueries), + ) tools = AgentSoulToolsConfig.model_validate( { "dify_tools": [ @@ -999,10 +1058,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=Mock(spec=WorkflowToolQueries), ) tools = AgentSoulToolsConfig.model_validate( { @@ -1030,10 +1091,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=Mock(spec=WorkflowToolQueries), ) tools = AgentSoulToolsConfig.model_validate( { @@ -1052,14 +1115,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=Mock(spec=WorkflowToolQueries), ) tools = AgentSoulToolsConfig.model_validate( { @@ -1078,12 +1144,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: [ @@ -1101,6 +1167,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=Mock(spec=WorkflowToolQueries), 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 ffcce5030421c1..1c8aec77e766bc 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 Mock import pytest from agenton.compositor import CompositorSessionSnapshot @@ -19,13 +20,6 @@ from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom 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_shell_layer_config, -) from graphon.file import File, FileTransferMethod, FileType from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding @@ -39,13 +33,21 @@ DeclaredOutputType, WorkflowNodeJobConfig, ) +from models.tool_runtime_contracts import WorkflowToolQueries +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_shell_layer_config, +) from tests.unit_tests.config_override import apply_config_overrides @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: [], ) @@ -66,7 +68,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 @@ -253,10 +255,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=Mock(spec=WorkflowToolQueries) + ) + ).build(context) dumped = result.request.model_dump(mode="json") layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]} @@ -370,7 +377,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."}, @@ -380,7 +387,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=Mock(spec=WorkflowToolQueries) + ) + ).build(context) dumped = result.request.model_dump(mode="json") layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]} @@ -389,7 +400,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", @@ -427,7 +438,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=Mock(spec=WorkflowToolQueries) + ) + ).build(context) dumped = result.request.model_dump(mode="json") layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]} @@ -464,7 +479,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( @@ -486,7 +501,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=Mock(spec=WorkflowToolQueries) + ) + ).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"] @@ -727,7 +746,7 @@ def test_builds_workflow_run_request_with_dify_plugin_tools_layer(monkeypatch: p assert dify_tools_builder.last_invoke_from == context.dify_context.invoke_from -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", @@ -808,7 +827,11 @@ def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config(): ) context = replace(context, snapshot=snapshot) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + ) + ).build(context) dumped = result.request.model_dump(mode="json") layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]} @@ -884,7 +907,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", @@ -919,14 +942,18 @@ def test_build_knowledge_layer_maps_disabled_score_threshold_to_zero(): ) context = replace(context, snapshot=snapshot) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + ) + ).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 -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", @@ -947,22 +974,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=Mock(spec=WorkflowToolQueries) + ) + ).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 = CompositorSessionSnapshot(layers=[]) context = replace(_context(), session_snapshot=session_snapshot) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + ) + ).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", @@ -974,10 +1009,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=Mock(spec=WorkflowToolQueries) + ) + ).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( id="binding-1", @@ -996,12 +1035,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=Mock(spec=WorkflowToolQueries) + ) + ).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( id="binding-1", @@ -1020,12 +1063,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=Mock(spec=WorkflowToolQueries) + ) + ).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( id="binding-1", @@ -1039,12 +1086,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=Mock(spec=WorkflowToolQueries) + ) + ).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( id="binding-1", @@ -1069,7 +1120,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=Mock(spec=WorkflowToolQueries) + ) + ).build(context) output_schema = result.request.model_dump(mode="json")["composition"]["layers"][-1]["config"]["json_schema"] assert output_schema["properties"]["text"] == {"type": "string"} @@ -1114,7 +1169,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() @@ -1133,7 +1188,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=Mock(spec=WorkflowToolQueries) + ) + ).build(context) layers = _request_layers(result) agent_soul_prompt = layers["agent_soul_prompt"]["config"]["prefix"] @@ -1150,7 +1209,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): @@ -1177,7 +1236,11 @@ def get(self, selector): } ) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + ) + ).build(context) assert _request_layers(result)["workflow_node_job_prompt"]["config"]["user"] == ( "Review {{#previous-node.report#}} before responding." @@ -1188,7 +1251,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): @@ -1211,7 +1274,11 @@ def get(self, selector): } ) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + ) + ).build(context) assert _previous_node_prompt_payload(result, "previous-node.report") == { "transfer_method": "tool_file", @@ -1219,7 +1286,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( { @@ -1227,7 +1294,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=Mock(spec=WorkflowToolQueries) + ) + ).build(context) user_prompt = _workflow_user_prompt(result) @@ -1235,7 +1306,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( { @@ -1244,7 +1315,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=Mock(spec=WorkflowToolQueries) + ) + ).build(context) assert _request_layers(result)["workflow_node_job_prompt"]["config"]["user"] == ( "Review the current request without upstream context." @@ -1252,7 +1327,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") second_reference = build_file_reference(record_id="tool-file-2") @@ -1292,7 +1367,11 @@ def get(self, selector): } ) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + ) + ).build(context) assert _previous_node_prompt_payload(result, "previous-node.attachments") == [ { @@ -1306,7 +1385,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): @@ -1335,7 +1414,11 @@ def get(self, selector): } ) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + ) + ).build(context) user_prompt = _workflow_user_prompt(result) assert "- Uploaded workflow files:" in user_prompt @@ -1348,7 +1431,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): @@ -1375,7 +1458,11 @@ def get(self, selector): } ) - result = WorkflowAgentRuntimeRequestBuilder().build(context) + result = WorkflowAgentRuntimeRequestBuilder( + dify_tools_builder=WorkflowAgentDifyToolsBuilder( + tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + ) + ).build(context) assert _previous_node_prompt_payload(result, "previous-node.report") == { "transfer_method": "remote_url", @@ -1402,7 +1489,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(), @@ -1425,7 +1512,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§]."}, @@ -1449,7 +1536,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") @@ -1470,10 +1557,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=Mock(spec=WorkflowToolQueries) + ) + ).build(_context()) dumped = result.request.model_dump(mode="json") layers = {layer["name"]: layer for layer in dumped["composition"]["layers"]} @@ -1493,12 +1584,16 @@ def test_workflow_run_request_has_config_layer_with_empty_agent_soul(monkeypatch } -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=Mock(spec=WorkflowToolQueries) + ) + ).build(context) dumped = result.request.model_dump(mode="json") layer_names = [layer["name"] for layer in dumped["composition"]["layers"]] @@ -1530,9 +1625,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", @@ -1548,7 +1643,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=Mock(spec=WorkflowToolQueries) + ) + ).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 config.config.skills] == ["workspace-skill"] @@ -1557,18 +1656,22 @@ def test_workflow_run_request_includes_bound_workspace_skills(monkeypatch: pytes assert 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=Mock(spec=WorkflowToolQueries) + ) + ).build(context) soul_prompt = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt") assert soul_prompt.config.prefix == "You are careful. Use tender-analyzer and sample.pdf." assert "[§" not in 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={ @@ -1579,7 +1682,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=Mock(spec=WorkflowToolQueries) + ) + ).build(context) soul_prompt = next(layer for layer in result.request.composition.layers if layer.name == "agent_soul_prompt") assert soul_prompt.config.prefix == "Use Ghost Skill, Ghost File, and no-label.txt." @@ -1592,7 +1699,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"), @@ -1607,7 +1714,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( { @@ -1635,7 +1742,7 @@ def test_build_config_layer_config_excludes_missing_assets_from_runtime(): def test_build_ask_human_layer_config_gated_on_human_contacts(): from dify_agent.layers.ask_human import DifyAskHumanLayerConfig - from core.workflow.nodes.agent_v2.runtime_request_builder import build_ask_human_layer_config + from services.workflow.execution.adapters.agent_v2.runtime_request_builder import build_ask_human_layer_config # no human involvement configured -> tool stays off assert build_ask_human_layer_config(AgentSoulConfig()) is None 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 328832171e8e25..1d2145f31212b4 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 @@ -4,25 +4,19 @@ 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.unit_tests.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.unit_tests.model_factories import make_dataset, make_workflow def _workflow(graph: dict) -> Workflow: @@ -144,7 +138,9 @@ def test_historical_agent_version_two_is_not_validated_as_dify_agent() -> None: } session = Mock() - WorkflowAgentNodeValidator.validate_published_workflow(session=session, workflow=_workflow(graph)) + WorkflowAgentNodeValidator(repository=WorkflowAgentBindingRepository(session)).validate_published_workflow( + workflow=workflow_binding_scope(_workflow(graph)) + ) session.scalar.assert_not_called() @@ -190,14 +186,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"}, + ] + ) ) ), ) @@ -218,9 +215,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"}]))), ) @@ -231,9 +227,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"}]))), ) @@ -244,23 +241,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"}]))), ) @@ -270,9 +269,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"}]))), ) @@ -282,20 +280,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", @@ -304,12 +303,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"}]))), ) @@ -318,17 +315,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"}]))), ) @@ -344,9 +341,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"}]))), ) @@ -362,9 +360,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"}]))), ) @@ -398,9 +397,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"}]))), ) @@ -431,9 +431,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"}]))), ) @@ -451,9 +450,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"}]))), ) @@ -471,9 +471,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"}]))), ) @@ -493,9 +494,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"}]))), ) @@ -513,9 +515,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"}]))), ) @@ -543,9 +546,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( @@ -569,9 +573,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"}]))), ) @@ -582,9 +587,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"}]))), ) @@ -595,9 +601,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"}]))), ) @@ -606,9 +613,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"}]))), ) @@ -617,9 +625,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"}]))), ) @@ -630,9 +639,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"}]))), ) @@ -654,9 +664,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"}]))), ) @@ -665,74 +674,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}}})) + ), ) @@ -741,9 +746,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): @@ -760,14 +769,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"}, + ] + ) ) ), ) @@ -786,4 +798,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/base/test_base_node.py b/api/tests/unit_tests/core/workflow/nodes/base/test_base_node.py index ec4cef1955af13..47e229b4594a0c 100644 --- a/api/tests/unit_tests/core/workflow/nodes/base/test_base_node.py +++ b/api/tests/unit_tests/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/unit_tests/core/workflow/nodes/base/test_get_node_type_classes_mapping.py b/api/tests/unit_tests/core/workflow/nodes/base/test_get_node_type_classes_mapping.py index ef0df55995e0fc..1120d6f2cdf579 100644 --- a/api/tests/unit_tests/core/workflow/nodes/base/test_get_node_type_classes_mapping.py +++ b/api/tests/unit_tests/core/workflow/nodes/base/test_get_node_type_classes_mapping.py @@ -1,7 +1,6 @@ import types from collections.abc import Mapping -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 @@ -13,6 +12,7 @@ from graphon.nodes.variable_assigner.v2.node import ( VariableAssignerNode as VariableAssignerV2, ) +from services.workflow.execution.adapters.node_factory import get_node_type_classes_mapping def test_variable_assigner_latest_prefers_highest_numeric_version(): diff --git a/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node_stream.py b/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node_stream.py index 4a26d3d29e1abd..c043e40f6e9527 100644 --- a/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node_stream.py +++ b/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node_stream.py @@ -2,27 +2,25 @@ 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 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 graphon.enums import WorkflowNodeExecutionStatus from graphon.node_events import NodeRunResult, StreamCompletedEvent from graphon.runtime import GraphRuntimeState, VariablePool +from services.workflow.execution.adapters.node_factory import DifyNodeFactory from tests.workflow_test_utils import build_test_graph_init_params @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, use_factory: bool) -> None: variable_pool = VariablePool.from_bootstrap( system_variables=build_system_variables( datasource_type="online_document", @@ -72,15 +70,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( + graph_init_params=graph_init_params, + graph_runtime_state=graph_runtime_state, + workflow_runtime=replace(workflow_runtime, datasource_credentials=load_credentials), ) - factory = DifyNodeFactory(graph_init_params=graph_init_params, graph_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/http_request/test_http_request_node.py b/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_node.py index afde541beb3a35..3019a00ad11ce6 100644 --- a/api/tests/unit_tests/core/workflow/nodes/http_request/test_http_request_node.py +++ b/api/tests/unit_tests/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 GraphRuntimeState, 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/unit_tests/core/workflow/nodes/human_input/test_entities.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_entities.py index 905742ae5e2633..65bd195df58d6d 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 @@ -13,16 +13,6 @@ from pydantic import ValidationError from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY -from core.repositories.human_input_repository import ( - FormCreateParams, - HumanInputFormEntity, - HumanInputFormRecipientEntity, - 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 ( ButtonStyle, @@ -42,15 +32,21 @@ from graphon.runtime import GraphRuntimeState, VariablePool from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment from libs.datetime_utils import naive_utc_now +from models.human_input_contracts import ( + FormCreateParams, + HumanInputFormEntity, + 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, @@ -61,13 +57,16 @@ StringSource, UserActionConfig, ) +from services.workflow.execution.adapters.human_input import ( + DifyHITLCallback, +) +from services.workflow.execution.adapters.node_runtime import DifyHumanInputNodeRuntime @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 @@ -79,10 +78,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 [] @@ -132,7 +127,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 @@ -201,11 +195,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.""" @@ -312,7 +306,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( @@ -346,7 +340,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( @@ -517,13 +511,11 @@ def test_resolves_variable_defaults(self): mock_repo.create_form.return_value = SimpleNamespace( id="form-1", rendered_content="Provide your name", - submission_token="token", recipients=[], submitted=False, ) - runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) - runtime._build_form_repository = MagicMock(return_value=mock_repo) # type: ignore[attr-defined] + runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context, forms=lambda **_kwargs: mock_repo) node = _build_human_input_node( node_id=config["id"], node_data=config["data"], @@ -583,13 +575,11 @@ def test_debugger_falls_back_to_recipient_token_when_webapp_disabled(self): mock_repo.create_form.return_value = SimpleNamespace( id="form-2", rendered_content="Provide your name", - submission_token="console-token", recipients=[SimpleNamespace(token="recipient-token")], submitted=False, ) - runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) - runtime._build_form_repository = MagicMock(return_value=mock_repo) # type: ignore[attr-defined] + runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context, forms=lambda **_kwargs: mock_repo) node = _build_human_input_node( node_id=config["id"], node_data=config["data"], @@ -648,13 +638,11 @@ def test_webapp_runtime_keeps_form_visible_in_ui_when_webapp_delivery_is_enabled mock_repo.create_form.return_value = SimpleNamespace( id="form-4", rendered_content="Provide your name", - submission_token="token", recipients=[], submitted=False, ) - runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) - runtime._build_form_repository = MagicMock(return_value=mock_repo) # type: ignore[attr-defined] + runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context, forms=lambda **_kwargs: mock_repo) node = _build_human_input_node( node_id=config["id"], node_data=config["data"], @@ -724,13 +712,11 @@ def test_debugger_debug_mode_overrides_email_recipients(self): mock_repo.create_form.return_value = SimpleNamespace( id="form-3", rendered_content="Provide your name", - submission_token="token", recipients=[], submitted=False, ) - runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context) - runtime._build_form_repository = MagicMock(return_value=mock_repo) # type: ignore[attr-defined] + runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context, forms=lambda **_kwargs: mock_repo) node = _build_human_input_node( node_id=config["id"], node_data=config["data"], @@ -825,8 +811,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"], @@ -898,8 +883,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"], diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py index ca1595ff2216a4..bd98550a437d5e 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py @@ -1,26 +1,15 @@ import datetime import json from collections.abc import Generator, Iterable, Mapping, Sequence -from contextlib import nullcontext from types import SimpleNamespace from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock import pytest from flask import Flask -from sqlalchemy.orm import Session 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 -from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner from core.app.entities.app_invoke_entities import ( DIFY_RUN_CONTEXT_KEY, AdvancedChatAppGenerateEntity, @@ -34,18 +23,7 @@ QueueWorkflowStartedEvent, WorkflowQueueMessage, ) -from core.repositories.human_input_repository import ( - HumanInputFormEntity, - HumanInputFormRecord, - HumanInputFormRepository, - HumanInputFormSubmissionRepository, -) -from core.workflow.nodes.human_input.boundary import HumanInputFormEventFilter -from core.workflow.nodes.human_input.callback import ( - DifyHITLCallback, -) 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 HumanInputFormStatus from graphon.entities import GraphInitParams, WorkflowStartReason from graphon.enums import BuiltinNodeTypes @@ -73,6 +51,7 @@ from libs.helper import compact_generate_response from models.account import Account from models.enums import MessageStatus +from models.human_input_contracts import HumanInputFormRecord from models.human_input_entities import ( FileInputConfig, FileListInputConfig, @@ -82,7 +61,20 @@ StringListSource, UserActionConfig, ) -from models.model import AppMode, Message +from models.model import AppMode +from repositories.human_input.form_repository import HumanInputFormSubmissionRepository +from services.app.generation.ports import ConversationSnapshot, MessageSnapshot, WorkflowSnapshot +from services.app.generation.response import convert_to_event_stream +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.workflow_entry import WorkflowEntry, iter_dify_graph_engine_events class _FakeFormRepository: @@ -357,20 +349,39 @@ def _publish_graph_events(events: Iterable[GraphEngineEvent]) -> list[AppQueueEv published: list[AppQueueEvent] = [] queue_manager = MagicMock(spec=AppQueueManager) queue_manager.publish.side_effect = lambda event, _publish_from: published.append(event) - runner = WorkflowBasedAppRunner(queue_manager=queue_manager, app_id="app") + runner = WorkflowEventPublisher(queue_manager, resolve_pause=lambda **_: [], notify_pause=lambda _: None) workflow_entry = MagicMock(spec=WorkflowEntry) for event in events: - runner._handle_event(workflow_entry, event) + runner.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, workflow_run_id): + pass + + def record_human_input(self, identity, *, workflow_run_id, form_id, node_id): + pass + + def save_message(self, identity, update): + pass + + def fail_message(self, identity, error): + pass + + def _sse_payloads( events: Sequence[AppQueueEvent], invoke_from: InvokeFrom, app: Flask, runtime_state: GraphRuntimeState | None = None, + *, + workflow_contexts, + tool_providers, ) -> list[dict[str, Any]]: generate_entity = AdvancedChatAppGenerateEntity( task_id="task-1", @@ -402,6 +413,8 @@ def _sse_payloads( WorkflowQueueMessage(task_id="task-1", app_mode=AppMode.ADVANCED_CHAT, event=event) for event in events ) pipeline = AdvancedChatAppGenerateTaskPipeline( + contexts=workflow_contexts, + chat_records=StreamMessageWrites(), application_generate_entity=generate_entity, workflow=WorkflowSnapshot(id="workflow", tenant_id="tenant", features_dict={}), queue_manager=queue_manager, @@ -413,23 +426,9 @@ def _sse_payloads( stream=True, dialogue_count=1, draft_var_saver_factory=MagicMock(), + tool_providers=tool_providers, ) - session = MagicMock(spec=Session) - session.scalar.return_value = None - form = MagicMock(spec=HumanInputFormEntity) - form.id = "form-1" - form_repository = MagicMock(spec=HumanInputFormRepository) - form_repository.get_form.return_value = form - - with ( - app.test_request_context(), - patch.object(pipeline, "_database_session", return_value=nullcontext(session)), - patch.object(pipeline, "_get_message", return_value=Message(id="message-1", status=MessageStatus.PAUSED)), - patch( - "core.app.apps.advanced_chat.generate_task_pipeline.HumanInputFormRepositoryImpl", - return_value=form_repository, - ), - ): + with app.test_request_context(): if not any(isinstance(event, QueueWorkflowStartedEvent) for event in events): list( pipeline._handle_workflow_started_event( @@ -437,7 +436,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 ) @@ -450,10 +449,14 @@ 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): +def test_submitted_human_input_reaches_response_stream( + invoke_from: InvokeFrom, app: Flask, *, workflow_contexts, tool_providers +): events = _publish_node_events(_build_node()) - payloads = _sse_payloads(events, invoke_from, app) + payloads = _sse_payloads( + events, invoke_from, app, 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. @@ -481,10 +484,12 @@ def test_submitted_human_input_reaches_response_stream(invoke_from: InvokeFrom, assert submitted_data["attachments"][0]["type"] == "image" -def test_button_only_human_input_reaches_response_stream(app: Flask): +def test_button_only_human_input_reaches_response_stream(app: Flask, *, workflow_contexts, tool_providers): events = _publish_node_events(_build_node("Approve deployment?", with_inputs=False)) - payloads = _sse_payloads(events, InvokeFrom.WEB_APP, app) + payloads = _sse_payloads( + events, InvokeFrom.WEB_APP, app, workflow_contexts=workflow_contexts, tool_providers=tool_providers + ) assert [payload["event"] for payload in payloads] == [ "node_started", @@ -498,7 +503,7 @@ def test_button_only_human_input_reaches_response_stream(app: Flask): @pytest.mark.parametrize("status", [HumanInputFormStatus.TIMEOUT, HumanInputFormStatus.WAITING]) def test_timed_out_human_input_reaches_response_stream( - status: HumanInputFormStatus, monkeypatch: pytest.MonkeyPatch, app: Flask + status: HumanInputFormStatus, monkeypatch: pytest.MonkeyPatch, app: Flask, *, workflow_contexts, tool_providers ): expiration_time = datetime.datetime(2025, 1, 1) form = MagicMock(spec=HumanInputFormRecord) @@ -516,7 +521,9 @@ def test_timed_out_human_input_reaches_response_stream( events = _publish_node_events(_build_timeout_node(expiration_time, status=status)) - payloads = _sse_payloads(events, InvokeFrom.WEB_APP, app) + payloads = _sse_payloads( + events, InvokeFrom.WEB_APP, app, workflow_contexts=workflow_contexts, tool_providers=tool_providers + ) assert [payload["event"] for payload in payloads] == [ "node_started", "human_input_form_timeout", @@ -532,7 +539,7 @@ 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, monkeypatch: pytest.MonkeyPatch, app: Flask + timed_out: bool, terminal: str, monkeypatch: pytest.MonkeyPatch, app: Flask, *, workflow_contexts, tool_providers ): expiration_time = datetime.datetime(2025, 1, 1) node = ( @@ -585,8 +592,15 @@ def test_human_input_completion_and_referenced_answer_reach_response_stream( repository.get_by_form_id.return_value = form monkeypatch.setattr(HumanInputFormSubmissionRepository, "get_by_form_id", repository.get_by_form_id) - events = _publish_graph_events(iter_dify_graph_engine_events(engine)) - payloads = _sse_payloads(events, InvokeFrom.WEB_APP, app, runtime_state) + events = _publish_graph_events(iter_dify_graph_engine_events(engine, human_form_reader=repository)) + payloads = _sse_payloads( + events, + InvokeFrom.WEB_APP, + app, + 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"] 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 66948b66017a51..9ca184654e9c11 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 @@ -15,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 RAGRetrievalProtocol, Source 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 GraphRuntimeState, VariablePool from graphon.variables import 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 @@ -63,7 +63,7 @@ def mock_rag_retrieval(mocker: MockerFixture): mock_retrieval.knowledge_retrieval.return_value = [] mock_retrieval.llm_usage = LLMUsage.empty_usage() mocker.patch( - "core.workflow.nodes.knowledge_retrieval.knowledge_retrieval_node.DatasetRetrieval", + "services.workflow.execution.adapters.knowledge_retrieval.DatasetRetrieval", return_value=mock_retrieval, ) return mock_retrieval @@ -120,6 +120,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"]), graph_init_params=mock_graph_init_params, @@ -179,6 +180,7 @@ def test_run_with_query_variable_single_mode( mock_rag_retrieval.llm_usage = LLMUsage.empty_usage() node = KnowledgeRetrievalNode( + retrieval=mock_rag_retrieval, node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, @@ -223,6 +225,7 @@ def test_run_with_query_variable_multiple_mode( mock_rag_retrieval.llm_usage = LLMUsage.empty_usage() node = KnowledgeRetrievalNode( + retrieval=mock_rag_retrieval, node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, @@ -259,6 +262,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"]), graph_init_params=mock_graph_init_params, @@ -294,6 +298,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"]), graph_init_params=mock_graph_init_params, @@ -335,6 +340,7 @@ def test_run_with_rate_limit_exceeded( mock_rag_retrieval.llm_usage = LLMUsage.empty_usage() node = KnowledgeRetrievalNode( + retrieval=mock_rag_retrieval, node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, @@ -374,6 +380,7 @@ def test_run_with_generic_exception( mock_rag_retrieval.llm_usage = LLMUsage.empty_usage() node = KnowledgeRetrievalNode( + retrieval=mock_rag_retrieval, node_id=node_id, data=KnowledgeRetrievalNodeData.model_validate(config["data"]), graph_init_params=mock_graph_init_params, @@ -455,6 +462,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"]), graph_init_params=mock_graph_init_params, @@ -462,7 +470,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 @@ -492,6 +500,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"]), graph_init_params=mock_graph_init_params, @@ -499,7 +508,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) @@ -547,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"]), graph_init_params=mock_graph_init_params, @@ -554,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) @@ -587,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"]), graph_init_params=mock_graph_init_params, @@ -648,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"]), graph_init_params=mock_graph_init_params, @@ -658,7 +670,7 @@ def test_fetch_passes_resolved_metadata_conditions( mock_rag_retrieval.llm_usage = LLMUsage.empty_usage() # 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/parameter_extractor/test_parameter_extractor_execution_behavior.py b/api/tests/unit_tests/core/workflow/nodes/parameter_extractor/test_parameter_extractor_execution_behavior.py index 42950c563e40da..cdf9dc97ee57ef 100644 --- a/api/tests/unit_tests/core/workflow/nodes/parameter_extractor/test_parameter_extractor_execution_behavior.py +++ b/api/tests/unit_tests/core/workflow/nodes/parameter_extractor/test_parameter_extractor_execution_behavior.py @@ -10,7 +10,6 @@ from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from core.model_manager import ModelInstance -from core.workflow.node_runtime import DifyPromptMessageSerializer from core.workflow.system_variables import build_system_variables from extensions.ext_database import db from graphon.enums import WorkflowNodeExecutionStatus @@ -21,6 +20,7 @@ from graphon.nodes.parameter_extractor.entities import ParameterExtractorNodeData from graphon.nodes.parameter_extractor.parameter_extractor_node import ParameterExtractorNode from graphon.runtime import GraphRuntimeState, VariablePool +from services.workflow.execution.adapters.node_runtime import DifyPromptMessageSerializer from tests.unit_tests.core.workflow.nodes.parameter_extractor.fixtures.model import get_mocked_fetch_model_instance from tests.workflow_test_utils import build_test_graph_init_params diff --git a/api/tests/unit_tests/core/workflow/nodes/template_transform/test_template_transform_execution.py b/api/tests/unit_tests/core/workflow/nodes/template_transform/test_template_transform_execution.py index 086ca44ee9964b..9cda093f6e0a52 100644 --- a/api/tests/unit_tests/core/workflow/nodes/template_transform/test_template_transform_execution.py +++ b/api/tests/unit_tests/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 GraphRuntimeState, 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/unit_tests/core/workflow/nodes/test_base_node.py b/api/tests/unit_tests/core/workflow/nodes/test_base_node.py index a05151f79b0164..dc9e4db7554c90 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_base_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/test_base_node.py @@ -3,13 +3,13 @@ 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 import GraphInitParams from graphon.entities.base_node_data import BaseNodeData from graphon.enums import BuiltinNodeTypes from graphon.nodes.base.node import Node from graphon.runtime import GraphRuntimeState, 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/unit_tests/core/workflow/nodes/test_if_else.py b/api/tests/unit_tests/core/workflow/nodes/test_if_else.py index 38d1e94bbc9abb..6cf99f6e5f2465 100644 --- a/api/tests/unit_tests/core/workflow/nodes/test_if_else.py +++ b/api/tests/unit_tests/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 GraphRuntimeState, 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/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py index 4b41e28ec8e543..14e97588fc4857 100644 --- a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py @@ -6,29 +6,27 @@ from unittest.mock import MagicMock, patch import pytest -from sqlalchemy import Engine -from sqlalchemy.orm import sessionmaker from core.callback_handler.workflow_tool_callback_handler import DifyWorkflowCallbackHandler from core.plugin.impl.exc import PluginDaemonClientSideError, PluginInvokeError 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.message_transformer import ToolFileMessageTransformer -from core.workflow.node_runtime import DifyToolNodeRuntime from core.workflow.system_variables import build_system_variables from graphon.model_runtime.entities.llm_entities import LLMUsage from graphon.nodes.tool.entities import ToolNodeData, ToolProviderType from graphon.nodes.tool.exc import ToolRuntimeInvocationError from graphon.nodes.tool_runtime_entities import ToolRuntimeHandle, ToolRuntimeMessage from graphon.runtime import VariablePool +from services.tools.tool_engine import ToolEngine +from services.tools.tool_manager import ToolManager +from services.workflow.execution.adapters.node_runtime import DifyToolNodeRuntime from tests.workflow_test_utils import build_test_graph_init_params, build_test_variable_pool @pytest.fixture -def runtime(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> DifyToolNodeRuntime: +def runtime(monkeypatch: pytest.MonkeyPatch, workflow_runtime) -> DifyToolNodeRuntime: module_name = "core.ops.ops_trace_manager" if module_name not in sys.modules: ops_stub = types.ModuleType(module_name) @@ -46,8 +44,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) def _build_tool_node_data() -> ToolNodeData: @@ -109,7 +106,7 @@ def test_invoke_creates_callback_and_converts_messages(runtime: DifyToolNodeRunt callback = generic_invoke_mock.call_args.kwargs["workflow_tool_callback"] assert isinstance(callback, DifyWorkflowCallbackHandler) - assert generic_invoke_mock.call_args.kwargs["session"] is not None + assert "session" not in generic_invoke_mock.call_args.kwargs assert generic_invoke_mock.call_args.kwargs["conversation_id"] == "conversation-id" transform_kwargs = transform_tool_messages.call_args.kwargs @@ -333,28 +330,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("core.workflow.node_runtime.BuiltinToolManageService.list_builtin_tools", return_value=[builtin_tool]), + patch("services.workflow.execution.adapters.node_runtime.PluginInstaller") as installer_cls, + patch( + "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 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 = [] diff --git a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py index fccb5ab1c3b876..ddca2300c85e48 100644 --- a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py +++ b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_file_conversion.py @@ -16,11 +16,11 @@ WebhookBodyParameter, WebhookData, ) -from core.workflow.nodes.trigger_webhook.node import TriggerWebhookNode from core.workflow.system_variables import default_system_variables from graphon.entities import GraphInitParams from graphon.enums import WorkflowNodeExecutionStatus from graphon.runtime import GraphRuntimeState, VariablePool +from services.workflow.execution.adapters.trigger_webhook import TriggerWebhookNode from tests.workflow_test_utils import build_test_variable_pool @@ -133,8 +133,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() @@ -320,8 +320,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() @@ -386,8 +386,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/nodes/webhook/test_webhook_node.py b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py index d99189c446c1ff..e135afb03b9972 100644 --- a/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/webhook/test_webhook_node.py @@ -11,13 +11,13 @@ WebhookData, WebhookParameter, ) -from core.workflow.nodes.trigger_webhook.node import TriggerWebhookNode from core.workflow.system_variables import default_system_variables from graphon.entities import GraphInitParams from graphon.enums import WorkflowNodeExecutionStatus from graphon.file import File, FileTransferMethod, FileType from graphon.runtime import GraphRuntimeState, 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 diff --git a/api/tests/unit_tests/core/workflow/test_enrich_pause_reasons.py b/api/tests/unit_tests/core/workflow/test_enrich_pause_reasons.py index d930b0d9076bb2..654f7553aad34b 100644 --- a/api/tests/unit_tests/core/workflow/test_enrich_pause_reasons.py +++ b/api/tests/unit_tests/core/workflow/test_enrich_pause_reasons.py @@ -2,11 +2,11 @@ import pytest -from core.repositories.human_input_repository import HumanInputFormSubmissionRepository from core.workflow.human_input_policy import FormDisposition, enrich_human_input_pause_reasons -from core.workflow.nodes.human_input.boundary import enrich_graph_pause_reasons from core.workflow.nodes.human_input.pause_reason import DifyHITLEventType from graphon.entities.pause_reason import HitlRequired +from repositories.human_input.form_repository import HumanInputFormSubmissionRepository +from services.workflow.execution.adapters.human_input_events import enrich_graph_pause_reasons _HUMAN_INPUT_REASON = {"TYPE": DifyHITLEventType.HUMAN_INPUT_REQUIRED, "form_id": "f1"} diff --git a/api/tests/unit_tests/core/workflow/test_form_input_serialization_compat.py b/api/tests/unit_tests/core/workflow/test_form_input_serialization_compat.py index 3924a8f9821263..005ebd6b3dedec 100644 --- a/api/tests/unit_tests/core/workflow/test_form_input_serialization_compat.py +++ b/api/tests/unit_tests/core/workflow/test_form_input_serialization_compat.py @@ -11,7 +11,11 @@ from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from core.workflow.nodes.human_input.session_binding import SessionBinding from enums.human_input import ButtonStyle, TimeoutUnit, ValueSourceType -from models.human_input_entities import FormDefinition, FormInputConfig, HumanInputNodeData +from models.human_input_entities import ( + FormDefinition, + FormInputConfig, + HumanInputNodeData, +) def _legacy_form_input_payloads() -> list[dict[str, Any]]: diff --git a/api/tests/unit_tests/core/workflow/test_human_input_adapter.py b/api/tests/unit_tests/core/workflow/test_human_input_adapter.py index ffe94075291779..1c7cccca992fdb 100644 --- a/api/tests/unit_tests/core/workflow/test_human_input_adapter.py +++ b/api/tests/unit_tests/core/workflow/test_human_input_adapter.py @@ -14,9 +14,13 @@ from graphon.enums import BuiltinNodeTypes from graphon.nodes.base.variable_template_parser import VariableTemplateParser from graphon.runtime import VariablePool -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, +) def test_email_delivery_config_helpers_render_and_sanitize_text() -> None: @@ -263,7 +267,7 @@ class _NodeModel(BaseModel): { "type": DeliveryMethodType.WEBAPP, "enabled": True, - "config": _WebAppDeliveryConfig().model_dump(mode="python"), + "config": _InteractiveSurfaceDeliveryConfig().model_dump(mode="python"), } ] ) @@ -355,7 +359,7 @@ def test_email_delivery_method_extracts_variable_selectors_skips_short_selectors def test_webapp_delivery_method_uses_empty_selector_set() -> None: - method = WebAppDeliveryMethod(enabled=True, config=_WebAppDeliveryConfig()) + method = InteractiveSurfaceDeliveryMethod(enabled=True, config=_InteractiveSurfaceDeliveryConfig()) assert method.extract_variable_selectors() == () diff --git a/api/tests/unit_tests/core/workflow/test_human_input_callback.py b/api/tests/unit_tests/core/workflow/test_human_input_callback.py index 5c611528c13870..ecd2f7810b7927 100644 --- a/api/tests/unit_tests/core/workflow/test_human_input_callback.py +++ b/api/tests/unit_tests/core/workflow/test_human_input_callback.py @@ -7,14 +7,14 @@ import pytest -from core.repositories.human_input_repository import FormCreateParams, HumanInputFormRepository -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 from graphon.runtime import VariablePool from graphon.variables.factory import build_segment from libs.datetime_utils import naive_utc_now +from models.human_input_contracts import FormCreateParams, HumanInputFormRepository from models.human_input_entities import HumanInputNodeData, ParagraphInputConfig, UserActionConfig +from services.workflow.execution.adapters.human_input import DifyHITLCallback @dataclass(frozen=True, slots=True) 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 f5c7b022740faf..e95ab8c188775a 100644 --- a/api/tests/unit_tests/core/workflow/test_node_factory.py +++ b/api/tests/unit_tests/core/workflow/test_node_factory.py @@ -3,17 +3,13 @@ from unittest.mock import MagicMock, Mock, patch, sentinel import pytest -from sqlalchemy import Engine -from sqlalchemy.orm import Session, sessionmaker from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext, InvokeFrom, UserFrom 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 template_rendering as workflow_template_rendering from core.workflow.llm_node import DifyLLMNode -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, NodeType @@ -26,33 +22,8 @@ from graphon.nodes.llm.runtime_protocols import LLMPollingCapableProtocol from graphon.nodes.parameter_extractor.entities import ParameterExtractorNodeData from graphon.variables.segments import ArrayObjectSegment, ObjectSegment, StringSegment -from models.base import TypeBase -from models.model import AppMode, Conversation, ConversationFromSource - - -@pytest.fixture -def memory_session_maker(monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine) -> sessionmaker[Session]: - """Bind node memory lookup to an explicit SQLite session factory.""" - - 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) - return session_maker - - -def _persist_conversation(session_maker: sessionmaker[Session]) -> None: - with session_maker.begin() as session: - session.add( - Conversation( - id="conversation-id", - app_id="app-id", - mode=AppMode.ADVANCED_CHAT, - name="Conversation", - _inputs={}, - from_source=ConversationFromSource.API, - from_end_user_id="end-user-id", - ) - ) +from services.workflow.execution.adapters import node_factory +from services.workflow.execution.adapters.node_runtime import DifyPreparedLLM def _assert_constructor_node_data(data, *, node_id: str, node_type: NodeType, version: str = "1") -> None: @@ -146,78 +117,6 @@ def test_document_extractor_latest_falls_back_to_registry_mapping(self, monkeypa assert resolved is latest_node_class -class TestFetchMemory: - @pytest.mark.parametrize( - ("conversation_id", "memory_config"), - [ - (None, object()), - ("conversation-id", None), - ], - ) - def test_returns_none_when_memory_or_conversation_is_missing(self, conversation_id, memory_config): - result = node_factory.fetch_memory( - conversation_id=conversation_id, - app_id="app-id", - node_data_memory=memory_config, - model_instance=sentinel.model_instance, - ) - - assert result is None - - def test_returns_none_when_conversation_does_not_exist(self, memory_session_maker: sessionmaker[Session]): - result = node_factory.fetch_memory( - conversation_id="conversation-id", - app_id="app-id", - node_data_memory=object(), - model_instance=sentinel.model_instance, - ) - - assert result is None - - def test_builds_token_buffer_memory_for_existing_conversation( - self, monkeypatch: pytest.MonkeyPatch, memory_session_maker: sessionmaker[Session] - ): - memory = sentinel.memory - _persist_conversation(memory_session_maker) - token_buffer_memory = MagicMock(return_value=memory) - monkeypatch.setattr(node_factory, "TokenBufferMemory", token_buffer_memory) - - result = node_factory.fetch_memory( - conversation_id="conversation-id", - app_id="app-id", - node_data_memory=object(), - model_instance=sentinel.model_instance, - ) - - assert result is memory - loaded_conversation = token_buffer_memory.call_args.kwargs["conversation"] - assert isinstance(loaded_conversation, Conversation) - assert loaded_conversation.id == "conversation-id" - assert token_buffer_memory.call_args.kwargs["model_instance"] is sentinel.model_instance - - def test_uses_configured_session_factory_without_flask_app_context( - self, monkeypatch: pytest.MonkeyPatch, memory_session_maker: sessionmaker[Session] - ): - class RaisingDB: - @property - def engine(self): - raise RuntimeError("Working outside of application context.") - - 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) - - result = node_factory.fetch_memory( - conversation_id="conversation-id", - app_id="app-id", - node_data_memory=object(), - model_instance=sentinel.model_instance, - ) - - assert result is sentinel.memory - - class TestDifyGraphInitContext: def test_to_graph_init_params_preserves_explicit_values(self): run_context = { @@ -344,8 +243,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.graph_init_params = sentinel.graph_init_params factory.graph_runtime_state = SimpleNamespace(variable_pool=MagicMock()) factory._dify_context = SimpleNamespace( @@ -367,7 +267,6 @@ def factory(self): factory._prompt_message_serializer = sentinel.prompt_message_serializer factory._retriever_attachment_loader = sentinel.retriever_attachment_loader factory._llm_file_saver = sentinel.llm_file_saver - factory._human_input_runtime = sentinel.human_input_runtime factory._tool_runtime = sentinel.tool_runtime factory._http_request_file_manager = sentinel.file_manager factory._document_extractor_unstructured_api_config = sentinel.unstructured_api_config @@ -1157,41 +1056,10 @@ def test_returns_none_when_memory_is_not_configured(self, factory): assert result is None factory.graph_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 - factory.graph_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), - model_instance=sentinel.model_instance, - ) - - assert result is sentinel.memory - factory.graph_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.graph_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 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 4ae69d44cb53cc..dfc9ee64f0bcb9 100644 --- a/api/tests/unit_tests/core/workflow/test_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/test_node_runtime.py @@ -1,36 +1,15 @@ -from collections.abc import Iterator -from datetime import UTC, datetime from types import SimpleNamespace from unittest.mock import MagicMock, Mock, sentinel -from uuid import uuid4 import pytest -from sqlalchemy import Engine, event -from sqlalchemy.orm import Session, sessionmaker from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, DifyRunContext, InvokeFrom, UserFrom -from core.app.file_access import FileAccessScope, bind_file_access_scope, grant_retriever_segment_access from core.llm_generator.output_parser.errors import OutputParserError from core.model_manager import QuotaManagedModelInstance from core.plugin.impl.exc import PluginLLMPollingUnsupportedError 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_runtime -from core.workflow.file_reference import parse_file_reference -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 enums.human_input import DeliveryMethodType from graphon.file import File, FileTransferMethod, FileType from graphon.model_runtime.entities.common_entities import I18nObject @@ -41,67 +20,31 @@ from graphon.nodes.llm.runtime_protocols import LLMPollingCapableProtocol from graphon.nodes.tool.entities import ToolNodeData, ToolProviderType from graphon.variables.segments import ArrayFileSegment, FileSegment -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.workflow.execution.adapters import node_runtime +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 tests.workflow_test_utils import build_test_run_context -@pytest.fixture -def attachment_session(sqlite_engine: Engine) -> Iterator[Session]: - """Provide real attachment and upload-file persistence to node runtime tests.""" - - TypeBase.metadata.create_all(sqlite_engine, tables=[SegmentAttachmentBinding.__table__, UploadFile.__table__]) - with Session(sqlite_engine, expire_on_commit=False) as session: - yield session - - -def _persist_attachment( - session: Session, - *, - segment_id: str, - upload_file_id: str, - upload_file_tenant_id: str = "tenant-id", -) -> UploadFile: - """Persist an attachment binding for the test tenant and its referenced upload file.""" - - upload_file = UploadFile( - tenant_id=upload_file_tenant_id, - storage_type=StorageType.LOCAL, - key="storage-key", - name="diagram.png", - size=128, - extension="png", - mime_type="image/png", - created_by_role=CreatorUserRole.ACCOUNT, - created_by="user-id", - created_at=datetime.now(UTC).replace(tzinfo=None), - used=False, - source_url="https://example.com/diagram.png", - ) - upload_file.id = upload_file_id - session.add_all( - [ - upload_file, - SegmentAttachmentBinding( - tenant_id="tenant-id", - dataset_id="dataset-id", - document_id="document-id", - segment_id=segment_id, - attachment_id=upload_file_id, - ), - ] - ) - session.commit() - return upload_file - - def _build_model_schema(*, features: list[ModelFeature] | None = None) -> AIModelEntity: return AIModelEntity( model="gpt-4o-mini", @@ -202,7 +145,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( @@ -550,168 +493,6 @@ def test_dify_prompt_message_serializer_delegates(monkeypatch: pytest.MonkeyPatc ) -def test_dify_retriever_attachment_loader_builds_graph_files( - monkeypatch: pytest.MonkeyPatch, attachment_session: Session -) -> 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())) - loader = DifyRetrieverAttachmentLoader( - file_reference_factory=SimpleNamespace(build_from_mapping=build_from_mapping) - ) - - files = loader.load(segment_id="segment-id") - - assert files == [sentinel.file] - build_from_mapping.assert_called_once() - mapping = build_from_mapping.call_args.kwargs["mapping"] - assert mapping["id"] == "upload-file-id" - assert mapping["transfer_method"] == FileTransferMethod.LOCAL_FILE - assert mapping["type"] == FileType.IMAGE - assert parse_file_reference(mapping["reference"]).storage_key is None - - -def test_dify_retriever_attachment_loader_grants_upload_files_for_allowed_segment( - monkeypatch: pytest.MonkeyPatch, - attachment_session: Session, -) -> None: - from factories.file_factory import builders as file_builders - - upload_file_id = str(uuid4()) - segment_id = str(uuid4()) - _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)) - session_maker = sessionmaker(engine, expire_on_commit=False) - monkeypatch.setattr(file_builders.session_factory, "create_session", session_maker) - - loader = DifyRetrieverAttachmentLoader(file_reference_factory=DifyFileReferenceFactory(_build_run_context())) - scope = FileAccessScope( - tenant_id="tenant-id", - user_id="end-user-id", - user_from=UserFrom.END_USER, - invoke_from=InvokeFrom.WEB_APP, - ) - - with bind_file_access_scope(scope): - grant_retriever_segment_access([segment_id]) - files = loader.load(segment_id=segment_id) - - assert files[0].related_id == upload_file_id - assert files[0].filename == "diagram.png" - - -def test_dify_retriever_attachment_loader_rejects_granted_upload_file_from_another_tenant( - monkeypatch: pytest.MonkeyPatch, - attachment_session: Session, -) -> None: - from factories.file_factory import builders as file_builders - - upload_file_id = str(uuid4()) - segment_id = str(uuid4()) - _persist_attachment( - attachment_session, - segment_id=segment_id, - upload_file_id=upload_file_id, - upload_file_tenant_id="other-tenant-id", - ) - engine = attachment_session.get_bind() - assert engine is not None - monkeypatch.setattr(node_runtime, "db", SimpleNamespace(engine=engine)) - monkeypatch.setattr(file_builders.session_factory, "create_session", sessionmaker(engine, expire_on_commit=False)) - - loader = DifyRetrieverAttachmentLoader(file_reference_factory=DifyFileReferenceFactory(_build_run_context())) - scope = FileAccessScope( - tenant_id="tenant-id", - user_id="end-user-id", - user_from=UserFrom.END_USER, - invoke_from=InvokeFrom.WEB_APP, - ) - - with bind_file_access_scope(scope): - grant_retriever_segment_access([segment_id]) - with pytest.raises(ValueError, match="Invalid upload file"): - loader.load(segment_id=segment_id) - - -def test_dify_retriever_attachment_loader_skips_ungranted_segment_for_end_user( - monkeypatch: pytest.MonkeyPatch, - attachment_session: Session, -) -> None: - build_from_mapping = MagicMock() - engine = attachment_session.get_bind() - assert engine is not None - monkeypatch.setattr(node_runtime, "db", SimpleNamespace(engine=engine)) - statement_count = 0 - - def count_statements(*_args, **_kwargs) -> None: - nonlocal statement_count - statement_count += 1 - - event.listen(engine, "before_cursor_execute", count_statements) - loader = DifyRetrieverAttachmentLoader( - file_reference_factory=SimpleNamespace(build_from_mapping=build_from_mapping) - ) - scope = FileAccessScope( - tenant_id="tenant-id", - user_id="end-user-id", - user_from=UserFrom.END_USER, - invoke_from=InvokeFrom.WEB_APP, - ) - - with bind_file_access_scope(scope): - files = loader.load(segment_id=str(uuid4())) - - try: - assert files == [] - assert statement_count == 0 - build_from_mapping.assert_not_called() - finally: - event.remove(engine, "before_cursor_execute", count_statements) - - -def test_dify_retriever_attachment_loader_skips_segment_rejected_by_checker( - monkeypatch: pytest.MonkeyPatch, - attachment_session: Session, -) -> None: - segment_id = str(uuid4()) - build_from_mapping = MagicMock() - 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)) - statement_count = 0 - - def count_statements(*_args, **_kwargs) -> None: - nonlocal statement_count - statement_count += 1 - - event.listen(engine, "before_cursor_execute", count_statements) - loader = DifyRetrieverAttachmentLoader( - file_reference_factory=SimpleNamespace(build_from_mapping=build_from_mapping), - segment_access_checker=segment_access_checker, - ) - scope = FileAccessScope( - tenant_id="tenant-id", - user_id="end-user-id", - user_from=UserFrom.END_USER, - invoke_from=InvokeFrom.WEB_APP, - ) - - with bind_file_access_scope(scope): - grant_retriever_segment_access([segment_id]) - files = loader.load(segment_id=segment_id) - - try: - assert files == [] - segment_access_checker.assert_called_once_with(segment_id) - assert statement_count == 0 - build_from_mapping.assert_not_called() - finally: - event.remove(engine, "before_cursor_execute", count_statements) - - def test_dify_tool_file_manager_resolves_conversation_id_for_tool_files(monkeypatch: pytest.MonkeyPatch) -> None: tool_file = ToolFile( user_id="user-id", @@ -772,6 +553,7 @@ def test_dify_tool_file_manager_delegates_file_generator_lookup(monkeypatch: pyt 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) @@ -784,7 +566,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, @@ -815,6 +597,7 @@ def test_dify_tool_node_runtime_injects_outer_workflow_run_id_for_workflow_tools 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) @@ -829,7 +612,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, @@ -855,13 +638,14 @@ def test_dify_tool_node_runtime_stores_trace_session_id_for_workflow_tools( 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, @@ -885,24 +669,30 @@ def test_dify_tool_node_runtime_does_not_inject_outer_workflow_run_id_for_non_wo get_runtime.assert_called_once() -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", + } + ] def test_dify_tool_runtime_spec_prefers_tool_parameters_for_runtime_form_values() -> None: @@ -946,14 +736,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( @@ -984,13 +774,13 @@ 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( @@ -1011,13 +801,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( @@ -1036,8 +826,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 -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_response_stream_filter_issue_170.py b/api/tests/unit_tests/core/workflow/test_response_stream_filter_issue_170.py index 8056da32acd3cc..ad8b6f1341872d 100644 --- a/api/tests/unit_tests/core/workflow/test_response_stream_filter_issue_170.py +++ b/api/tests/unit_tests/core/workflow/test_response_stream_filter_issue_170.py @@ -1,9 +1,11 @@ +from tests.unit_tests.workflow_execution import NO_HUMAN_INPUT_FORMS + """Unit coverage for Dify's in-memory ResponseStreamFilter boundary behavior.""" -from core.workflow.workflow_entry import iter_dify_graph_engine_events from graphon.graph_engine import GraphEngine, GraphEngineConfig from graphon.graph_engine.command_channels import InMemoryChannel from graphon.graph_events import GraphRunSucceededEvent, NodeRunStreamChunkEvent +from services.workflow.execution.adapters.workflow_entry import iter_dify_graph_engine_events from tests.unit_tests.core.workflow.graph_engine.test_mock_config import MockConfig, MockConfigBuilder from tests.unit_tests.core.workflow.graph_engine.test_table_runner import WorkflowRunner @@ -61,7 +63,7 @@ def test_dify_response_stream_filter_handles_issue_170_shape() -> None: command_channel=InMemoryChannel(), config=GraphEngineConfig(), ) - 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)] diff --git a/api/tests/unit_tests/core/workflow/test_response_stream_filter_pause_resume.py b/api/tests/unit_tests/core/workflow/test_response_stream_filter_pause_resume.py index bb9623af400c89..a796c7e72e7506 100644 --- a/api/tests/unit_tests/core/workflow/test_response_stream_filter_pause_resume.py +++ b/api/tests/unit_tests/core/workflow/test_response_stream_filter_pause_resume.py @@ -1,3 +1,5 @@ +from tests.unit_tests.workflow_execution import NO_HUMAN_INPUT_FORMS + """In-memory regression test: if-else branch + human_input pause + downstream answer nodes. Reproduces https://github.com/langgenius/dify/issues/38525 at the @@ -10,14 +12,7 @@ from unittest.mock import MagicMock from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom -from core.repositories.human_input_repository import ( - HumanInputFormEntity, - HumanInputFormRecipientEntity, - HumanInputFormRepository, -) -from core.workflow.nodes.human_input.callback import DifyHITLCallback from core.workflow.system_variables import build_system_variables -from core.workflow.workflow_entry import iter_dify_graph_engine_events from enums.human_input import HumanInputFormStatus from graphon.filters import GraphEventFilterContext, ResponseStreamFilter, filter_graph_events from graphon.graph import Graph @@ -35,7 +30,10 @@ from graphon.runtime import GraphRuntimeState, VariablePool from graphon.utils.condition.entities import Condition from libs.datetime_utils import naive_utc_now +from models.human_input_contracts import HumanInputFormEntity, HumanInputFormRecipientEntity, HumanInputFormRepository from models.human_input_entities import HumanInputNodeData, UserActionConfig +from services.workflow.execution.adapters.human_input import DifyHITLCallback +from services.workflow.execution.adapters.workflow_entry import iter_dify_graph_engine_events from tests.workflow_test_utils import build_test_graph_init_params WORKFLOW_EXECUTION_ID = "wf-exec-38525" @@ -45,7 +43,6 @@ def _mock_repo_paused() -> HumanInputFormRepository: repo = MagicMock(spec=HumanInputFormRepository) form = MagicMock(spec=HumanInputFormEntity) form.id = "form-1" - form.submission_token = "token-1" form.recipients = list[HumanInputFormRecipientEntity]() form.rendered_content = "rendered" form.submitted = False @@ -58,7 +55,6 @@ def _mock_repo_resumed(action_id: str = "continue") -> HumanInputFormRepository: repo = MagicMock(spec=HumanInputFormRepository) form = MagicMock(spec=HumanInputFormEntity) form.id = "form-1" - form.submission_token = "token-1" form.recipients = list[HumanInputFormRecipientEntity]() form.rendered_content = "rendered" form.submitted = True @@ -209,7 +205,7 @@ def test_if_else_human_input_pause_resume_answer_chunks_survive_resume() -> None 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=NO_HUMAN_INPUT_FORMS)) assert any(isinstance(e, GraphRunSucceededEvent) for e in phase2_events) 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 2357710594509a..b1870216ed7b4e 100644 --- a/api/tests/unit_tests/core/workflow/test_workflow_entry.py +++ b/api/tests/unit_tests/core/workflow/test_workflow_entry.py @@ -10,13 +10,13 @@ CONVERSATION_VARIABLE_NODE_ID, ENVIRONMENT_VARIABLE_NODE_ID, ) -from core.workflow.workflow_entry import WorkflowEntry from graphon.file import File, FileTransferMethod, FileType from graphon.nodes.code.code_node import CodeNode from graphon.nodes.code.limits import CodeNodeLimits from graphon.runtime import VariablePool from graphon.variables.variables import StringVariable from models.workflow import Workflow, WorkflowType +from services.workflow.execution.adapters.workflow_entry import WorkflowEntry @pytest.fixture(autouse=True) diff --git a/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py b/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py index 384340c7d0bba9..d049b89725e902 100644 --- a/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py +++ b/api/tests/unit_tests/core/workflow/test_workflow_entry_helpers.py @@ -8,7 +8,6 @@ from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from core.credit_usage import CreditUsageAppType -from core.workflow import workflow_entry from core.workflow.system_variables import default_system_variables from graphon.entities.base_node_data import BaseNodeData from graphon.enums import NodeType, WorkflowNodeExecutionStatus @@ -21,8 +20,10 @@ from graphon.runtime import VariablePool from graphon.variables.variables import StringVariable from models.workflow import Workflow +from services.workflow.execution.adapters import workflow_entry from tests.unit_tests.config_override import config_overrides_context from tests.unit_tests.model_factories import make_workflow +from tests.unit_tests.workflow_execution import NO_HUMAN_INPUT_FORMS def _build_typed_node_config(node_type: NodeType): @@ -61,6 +62,7 @@ def _build_minimal_workflow_entry( variable_pool=sentinel.variable_pool, graph_runtime_state=graph_runtime_state, response_stream_filter=response_stream_filter, + human_form_reader=NO_HUMAN_INPUT_FORMS, ) @@ -81,6 +83,7 @@ def test_rejects_call_depth_above_limit(self): call_depth=call_depth, variable_pool=sentinel.variable_pool, graph_runtime_state=sentinel.graph_runtime_state, + human_form_reader=NO_HUMAN_INPUT_FORMS, ) def test_applies_debug_and_observability_layers(self): @@ -118,6 +121,7 @@ def test_applies_debug_and_observability_layers(self): variable_pool=sentinel.variable_pool, graph_runtime_state=graph_runtime_state, command_channel=None, + human_form_reader=NO_HUMAN_INPUT_FORMS, ) assert entry.command_channel is sentinel.command_channel @@ -161,6 +165,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() @@ -193,7 +198,9 @@ def test_iter_dify_graph_engine_events_applies_response_stream_filter(self): return_value=iter([sentinel.filtered_event]), ) as filter_graph_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) @@ -206,6 +213,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 @@ -217,10 +225,13 @@ def test_run_delegates_to_dify_event_iterator(self): events = list(entry.run()) assert events == [sentinel.filtered_event] - iter_dify_graph_engine_events.assert_called_once_with(sentinel.graph_engine, sentinel.response_stream_filter) + iter_dify_graph_engine_events.assert_called_once_with( + 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() diff --git a/api/tests/unit_tests/core/workflow/test_workflow_entry_redis_channel.py b/api/tests/unit_tests/core/workflow/test_workflow_entry_redis_channel.py index 80dc8927fa9d20..5877d2c8d8ec37 100644 --- a/api/tests/unit_tests/core/workflow/test_workflow_entry_redis_channel.py +++ b/api/tests/unit_tests/core/workflow/test_workflow_entry_redis_channel.py @@ -1,11 +1,13 @@ +from tests.unit_tests.workflow_execution import NO_HUMAN_INPUT_FORMS + """Tests for WorkflowEntry integration with Redis command channel.""" from unittest.mock import MagicMock, patch from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom -from core.workflow.workflow_entry import WorkflowEntry from graphon.graph_engine.command_channels import RedisChannel from graphon.runtime import GraphRuntimeState, VariablePool +from services.workflow.execution.adapters.workflow_entry import WorkflowEntry class TestWorkflowEntryRedisChannel: @@ -25,7 +27,7 @@ def test_workflow_entry_uses_provided_redis_channel(self): redis_channel = RedisChannel(mock_redis_client, "test:channel:key") # Patch GraphEngine to verify it receives the Redis channel - with patch("core.workflow.workflow_entry.GraphEngine", autospec=True) as MockGraphEngine: + with patch("services.workflow.execution.adapters.workflow_entry.GraphEngine", autospec=True) as MockGraphEngine: mock_graph_engine = MockGraphEngine.return_value # Create WorkflowEntry with Redis channel workflow_entry = WorkflowEntry( tenant_id="test-tenant", @@ -40,6 +42,7 @@ def test_workflow_entry_uses_provided_redis_channel(self): variable_pool=mock_variable_pool, graph_runtime_state=mock_graph_runtime_state, command_channel=redis_channel, # Provide Redis channel + human_form_reader=NO_HUMAN_INPUT_FORMS, ) # Verify GraphEngine was initialized with the Redis channel @@ -59,8 +62,10 @@ def test_workflow_entry_defaults_to_inmemory_channel(self): # Patch GraphEngine and InMemoryChannel with ( - patch("core.workflow.workflow_entry.GraphEngine", autospec=True) as MockGraphEngine, - patch("core.workflow.workflow_entry.InMemoryChannel", autospec=True) as MockInMemoryChannel, + patch("services.workflow.execution.adapters.workflow_entry.GraphEngine", autospec=True) as MockGraphEngine, + patch( + "services.workflow.execution.adapters.workflow_entry.InMemoryChannel", autospec=True + ) as MockInMemoryChannel, ): mock_graph_engine = MockGraphEngine.return_value mock_inmemory_channel = MockInMemoryChannel.return_value # Create WorkflowEntry without providing a channel @@ -77,6 +82,7 @@ def test_workflow_entry_defaults_to_inmemory_channel(self): variable_pool=mock_variable_pool, graph_runtime_state=mock_graph_runtime_state, command_channel=None, # No channel provided + human_form_reader=NO_HUMAN_INPUT_FORMS, ) # Verify InMemoryChannel was created @@ -106,7 +112,7 @@ def test_workflow_entry_run_with_redis_channel(self): mock_event2 = MagicMock() # Patch GraphEngine - with patch("core.workflow.workflow_entry.GraphEngine", autospec=True) as MockGraphEngine: + with patch("services.workflow.execution.adapters.workflow_entry.GraphEngine", autospec=True) as MockGraphEngine: mock_graph_engine = MagicMock() mock_graph_engine.run.return_value = iter([mock_event1, mock_event2]) MockGraphEngine.return_value = mock_graph_engine @@ -125,6 +131,7 @@ def test_workflow_entry_run_with_redis_channel(self): variable_pool=mock_variable_pool, graph_runtime_state=mock_graph_runtime_state, command_channel=redis_channel, + human_form_reader=NO_HUMAN_INPUT_FORMS, ) # Run the workflow From 658517039fd40a5860bccc1715c0d1cae67394ce Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Fri, 9 Oct 2026 13:02:01 +0800 Subject: [PATCH 2/5] test(workflow): align graph fixtures and tool invocation with injected ports --- .../nodes/agent_v2/test_dify_tools_builder.py | 58 ++++++++++--------- .../agent_v2/test_runtime_request_builder.py | 58 ++++++++++--------- .../datasource/test_datasource_node_stream.py | 5 +- .../test_human_input_form_filled_event.py | 6 +- .../nodes/tool/test_tool_node_runtime.py | 20 +++++-- 5 files changed, 83 insertions(+), 64 deletions(-) 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 0d58610ea15afd..aa5e881bc71ccb 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,7 +2,7 @@ from collections.abc import Generator from typing import Any -from unittest.mock import Mock +from unittest.mock import create_autospec import pytest @@ -28,6 +28,10 @@ ) +def _workflow_queries() -> WorkflowToolQueries: + return create_autospec(WorkflowToolQueries, instance=True, spec_set=True) + + class FakeRuntimeProvider: def __init__(self, tool: Tool | Exception) -> None: # Either a Tool to hand back, or an exception to raise on lookup. The @@ -237,7 +241,7 @@ def test_builds_dify_plugin_tools_layer_from_existing_tool_runtime(tool_provider builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=runtime_provider, tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -276,7 +280,7 @@ def test_normalizes_fully_qualified_builtin_plugin_provider_for_daemon_transport builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=runtime_provider, tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -306,7 +310,7 @@ def test_builds_core_tool_with_file_llm_parameter(tool_providers): builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=runtime_provider, tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -345,7 +349,7 @@ def test_builds_plugin_tool_with_file_llm_parameter_schema(tool_providers): builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=runtime_provider, tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -380,7 +384,7 @@ def test_builds_plugin_tool_with_files_llm_parameter_schema(tool_providers): builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=runtime_provider, tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -410,7 +414,7 @@ def test_builds_builtin_compat_plugin_tool_with_files_llm_parameter_schema(tool_ builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=runtime_provider, tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -454,7 +458,7 @@ def test_build_layers_routes_plugin_direct_and_builtin_via_core(tool_providers) builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=runtime_provider, tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -504,7 +508,7 @@ def test_build_layers_routes_api_and_workflow_via_core(tool_providers, provider_ builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=runtime_provider, tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -539,7 +543,7 @@ def test_build_layers_rejects_app_provider_type(tool_providers) -> None: builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=runtime_provider, tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -570,7 +574,7 @@ def test_build_layers_rejects_dataset_retrieval_provider_type(tool_providers) -> builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=runtime_provider, tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -603,7 +607,7 @@ def test_builds_core_tool_with_missing_required_select_default(tool_providers): builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=runtime_provider, tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -638,7 +642,7 @@ def test_rejects_duplicate_exposed_tool_names(tool_providers): builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=runtime_provider, tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -674,7 +678,7 @@ def test_rejects_missing_required_runtime_parameter(tool_providers): builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=FakeRuntimeProvider(_tool(runtime_parameters={})), tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -713,7 +717,7 @@ def test_invoke_from_is_forwarded_to_tool_runtime_provider(tool_providers): builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=runtime_provider, tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -745,7 +749,7 @@ def test_disabled_tools_are_skipped(tool_providers): builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=runtime_provider, tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -776,7 +780,7 @@ def test_plugin_id_plus_provider_fallback_when_provider_id_missing(tool_provider builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=runtime_provider, tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -817,7 +821,7 @@ def _no_credentials_tool() -> FakeTool: builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=runtime_provider, tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -867,7 +871,7 @@ def test_tool_provider_not_found_maps_to_declaration_not_found(tool_providers): builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=FakeRuntimeProvider(ToolProviderNotFoundError("provider gone")), tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info: _build(builder, _standard_tools_payload()) @@ -880,7 +884,7 @@ def test_credential_validation_error_maps_to_credential_invalid(tool_providers): builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=FakeRuntimeProvider(ToolProviderCredentialValidationError("creds expired")), tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info: _build(builder, _standard_tools_payload()) @@ -896,7 +900,7 @@ def test_generic_value_error_maps_to_config_invalid(tool_providers): builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=FakeRuntimeProvider(ValueError("runtime missing")), tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info: _build(builder, _standard_tools_payload()) @@ -924,7 +928,7 @@ def _dict_credential_tool() -> FakeTool: builder = WorkflowAgentDifyToolsBuilder( tool_runtime_provider=FakeRuntimeProvider(_dict_credential_tool()), tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) with pytest.raises(WorkflowAgentDifyToolsBuildError) as exc_info: _build(builder, _standard_tools_payload()) @@ -1037,7 +1041,7 @@ def lister(*, tenant_id: str, provider_type: ToolProviderType, provider_id: str) tool_runtime_provider=runtime_provider, provider_tools_lister=lister, tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -1063,7 +1067,7 @@ def test_explicit_tool_entry_wins_over_provider_expansion(tool_providers): tool_runtime_provider=FakeRuntimeProvider(_tool()), provider_tools_lister=lambda *, tenant_id, provider_type, provider_id: ["search", "image_search"], tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -1096,7 +1100,7 @@ def test_provider_level_entry_with_no_tools_maps_to_declaration_not_found(tool_p tool_runtime_provider=FakeRuntimeProvider(_tool()), provider_tools_lister=lambda *, tenant_id, provider_type, provider_id: [], tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -1125,7 +1129,7 @@ def lister(*, tenant_id: str, provider_type: ToolProviderType, provider_id: str) tool_runtime_provider=FakeRuntimeProvider(_tool()), provider_tools_lister=lister, tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=_workflow_queries(), ) tools = AgentSoulToolsConfig.model_validate( { @@ -1168,7 +1172,7 @@ def fake_get_builtin_provider(provider_id, tenant_id): names = module._list_provider_tool_names( tool_providers=tool_providers, - queries=Mock(spec=WorkflowToolQueries), + 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 1c8aec77e766bc..517f5b4ee759ec 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,7 +2,7 @@ from dataclasses import replace from types import SimpleNamespace from typing import cast -from unittest.mock import Mock +from unittest.mock import create_autospec import pytest from agenton.compositor import CompositorSessionSnapshot @@ -44,6 +44,10 @@ from tests.unit_tests.config_override import apply_config_overrides +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( @@ -261,7 +265,7 @@ def test_builds_create_run_request_from_agent_soul_and_node_job( context = _context() result = WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -389,7 +393,7 @@ def test_normalizes_langgenius_model_provider_for_agent_backend_transport(tool_p ) result = WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -440,7 +444,7 @@ def test_builds_workflow_run_request_with_file_output_schema_and_reserved_metada result = WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -503,7 +507,7 @@ def test_build_maps_agent_soul_shell_settings_to_shell_layer(tool_providers, mon result = WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -829,7 +833,7 @@ def test_build_maps_agent_soul_knowledge_to_knowledge_layer_config(tool_provider result = WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -944,7 +948,7 @@ def test_build_knowledge_layer_maps_disabled_score_threshold_to_zero(tool_provid result = WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -976,7 +980,7 @@ def test_build_skips_knowledge_layer_when_agent_soul_has_no_sets(tool_providers) result = WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -990,7 +994,7 @@ def test_build_passes_saved_session_snapshot_to_agent_backend_request(tool_provi result = WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -1011,7 +1015,7 @@ def test_requires_agent_soul_model_config(tool_providers): with pytest.raises(WorkflowAgentRuntimeRequestBuildError, match="Agent Soul model"): WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -1037,7 +1041,7 @@ def test_missing_previous_node_output_fails_request_build(tool_providers): with pytest.raises(WorkflowAgentRuntimeRequestBuildError) as exc_info: WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -1065,7 +1069,7 @@ def test_invalid_previous_node_output_ref_fails_request_build(tool_providers): with pytest.raises(WorkflowAgentRuntimeRequestBuildError) as exc_info: WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -1088,7 +1092,7 @@ def test_empty_declared_outputs_omits_structured_output_layer(tool_providers): result = WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -1122,7 +1126,7 @@ def test_array_output_emits_typed_items_per_array_item(tool_providers): result = WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -1190,7 +1194,7 @@ def test_mentions_expand_in_soul_and_job_prompts_without_token_leak(tool_provide result = WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -1238,7 +1242,7 @@ def get(self, selector): result = WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -1276,7 +1280,7 @@ def get(self, selector): result = WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -1296,7 +1300,7 @@ def test_scalar_previous_node_output_appears_in_workflow_context_section(tool_pr result = WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -1317,7 +1321,7 @@ def test_stale_previous_node_refs_are_ignored_when_workflow_prompt_has_no_fronte result = WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -1369,7 +1373,7 @@ def get(self, selector): result = WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -1416,7 +1420,7 @@ def get(self, selector): result = WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -1460,7 +1464,7 @@ def get(self, selector): result = WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -1562,7 +1566,7 @@ def test_workflow_run_request_has_config_layer_with_empty_agent_soul(tool_provid result = WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(_context()) @@ -1591,7 +1595,7 @@ def test_workflow_run_request_contains_config_layer(tool_providers): result = WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -1645,7 +1649,7 @@ def test_workflow_run_request_includes_bound_workspace_skills(tool_providers, mo result = WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -1662,7 +1666,7 @@ def test_workflow_runtime_expands_config_mentions_in_agent_soul_prompt(tool_prov result = WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) @@ -1684,7 +1688,7 @@ def test_workflow_runtime_missing_config_mentions_fall_back_to_label_then_name(t result = WorkflowAgentRuntimeRequestBuilder( dify_tools_builder=WorkflowAgentDifyToolsBuilder( - tool_providers=tool_providers, workflow_queries=Mock(spec=WorkflowToolQueries) + tool_providers=tool_providers, workflow_queries=_workflow_queries() ) ).build(context) diff --git a/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node_stream.py b/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node_stream.py index c043e40f6e9527..8fa3127b4df23a 100644 --- a/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node_stream.py +++ b/api/tests/unit_tests/core/workflow/nodes/datasource/test_datasource_node_stream.py @@ -12,6 +12,7 @@ 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, StreamCompletedEvent from graphon.runtime import GraphRuntimeState, VariablePool @@ -20,7 +21,9 @@ @pytest.mark.parametrize("use_factory", [False, True]) -def test_node_integration_minimal_stream(mocker: MockerFixture, workflow_runtime, 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", diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py index 76577bd0a96733..a73403b4650ac1 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input/test_human_input_form_filled_event.py @@ -4,7 +4,7 @@ from decimal import Decimal from types import SimpleNamespace from typing import Any -from unittest.mock import MagicMock +from unittest.mock import MagicMock, create_autospec import pytest from flask import Flask @@ -402,10 +402,10 @@ def listen(self) -> Generator[WorkflowQueueMessage]: def _publish_graph_events(events: Iterable[GraphEngineEvent]) -> list[AppQueueEvent]: published: list[AppQueueEvent] = [] - queue_manager = MagicMock(spec=AppQueueManager) + 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, resolve_pause=lambda **_: [], notify_pause=lambda _: None) - workflow_entry = MagicMock(spec=WorkflowEntry) + workflow_entry = create_autospec(WorkflowEntry, instance=True, spec_set=True) for event in events: publisher.publish(workflow_entry, event) diff --git a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py index 14e97588fc4857..8db25589a471a9 100644 --- a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py +++ b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node_runtime.py @@ -2,8 +2,10 @@ import sys import types +from dataclasses import replace from types import SimpleNamespace -from unittest.mock import MagicMock, patch +from typing import cast +from unittest.mock import MagicMock, create_autospec, patch import pytest @@ -14,6 +16,7 @@ from core.tools.errors import ToolInvokeError from core.tools.utils.message_transformer import ToolFileMessageTransformer from core.workflow.system_variables import build_system_variables +from extensions.application_services.workflow import WorkflowExecutionDependencies from graphon.model_runtime.entities.llm_entities import LLMUsage from graphon.nodes.tool.entities import ToolNodeData, ToolProviderType from graphon.nodes.tool.exc import ToolRuntimeInvocationError @@ -22,11 +25,12 @@ from services.tools.tool_engine import ToolEngine from services.tools.tool_manager import ToolManager from services.workflow.execution.adapters.node_runtime import DifyToolNodeRuntime +from services.workflow.execution.ports import WorkflowToolInvoker from tests.workflow_test_utils import build_test_graph_init_params, build_test_variable_pool @pytest.fixture -def runtime(monkeypatch: pytest.MonkeyPatch, workflow_runtime) -> 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) @@ -73,10 +77,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", @@ -104,10 +112,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 "session" not in generic_invoke_mock.call_args.kwargs - 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" From 0b777f7ea54716d90c7480ef4d2da9f5c62a715e Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Tue, 20 Oct 2026 11:50:21 +0800 Subject: [PATCH 3/5] test(api): adapt tests to merged execution interfaces --- .../core/workflow/graph_engine/layers/test_observability.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/api/tests/unit_tests/core/workflow/graph_engine/layers/test_observability.py b/api/tests/unit_tests/core/workflow/graph_engine/layers/test_observability.py index 5ff3dd8c66f8a4..71e805260552b9 100644 --- a/api/tests/unit_tests/core/workflow/graph_engine/layers/test_observability.py +++ b/api/tests/unit_tests/core/workflow/graph_engine/layers/test_observability.py @@ -25,6 +25,7 @@ from graphon.nodes.start.entities import StartNodeData from graphon.runtime import RuntimeState, VariablePool from services.workflow.execution.adapters.workflow_entry import WorkflowEntry +from tests.unit_tests.workflow_execution import NO_HUMAN_INPUT_FORMS from tests.workflow_test_utils import build_test_graph_init_params @@ -75,6 +76,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()) From 324421761be91a223be8f342fe71347f9a81b181 Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Mon, 26 Oct 2026 21:14:25 +0800 Subject: [PATCH 4/5] Align workflow node tests with injected dependencies and canonical owners --- .../workflow/test_node_mapping_bootstrap.py | 21 ++++++++++-------- .../nodes/agent_v2/test_agent_node.py | 22 ++++++++++++++----- 2 files changed, 29 insertions(+), 14 deletions(-) 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 9d22c96c569054..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.nodes.datasource.datasource_node import DatasourceNode from core.workflow.nodes.knowledge_index import KNOWLEDGE_INDEX_NODE_TYPE + 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/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 5b4466e3010ae6..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 -): + 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 From 145baa50b7c53979694036db918c0736fbbaf081 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:31:10 +0000 Subject: [PATCH 5/5] [autofix.ci] apply automated fixes --- .../generated/api/console/apps/errors.gen.ts | 26 ------------------- .../generated/api/console/errors.gen.ts | 20 -------------- .../generated/api/console/rag/errors.gen.ts | 18 ------------- 3 files changed, 64 deletions(-) diff --git a/packages/contracts/generated/api/console/apps/errors.gen.ts b/packages/contracts/generated/api/console/apps/errors.gen.ts index 1ca81cfa7c000b..4cd4d5e228e674 100644 --- a/packages/contracts/generated/api/console/apps/errors.gen.ts +++ b/packages/contracts/generated/api/console/apps/errors.gen.ts @@ -605,8 +605,6 @@ export const get15Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, } -export const get16Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const post13Errors = { no_file_uploaded: { status: 400, data: zNoFileUploadedError }, too_many_files: { status: 400, data: zTooManyFilesError }, @@ -919,17 +917,10 @@ export const get65Errors = { app_not_found: { status: 404, data: zAppNotFoundErr export const get66Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const get67Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const post44Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const get68Errors = { - app_not_found: { status: 404, data: zAppNotFoundError }, draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError }, } -export const post45Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const post46Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } export const post47Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } @@ -993,10 +984,6 @@ export const post56Errors = { app_not_found: { status: 404, data: zAppNotFoundEr export const post57Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const delete11Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const get72Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const post58Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, rate_limit_error: { status: 429, data: zRateLimitError }, @@ -1025,8 +1012,6 @@ export const get76Errors = { workflow_run_not_found: { status: 404, data: zWorkflowRunNotFoundError }, } -export const get77Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const post59Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, rate_limit_error: { status: 429, data: zRateLimitError }, @@ -1037,18 +1022,7 @@ export const post60Errors = { rate_limit_error: { status: 429, data: zRateLimitError }, } -export const put6Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const delete12Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const get78Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const patch2Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const delete13Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const get79Errors = { - app_not_found: { status: 404, data: zAppNotFoundError }, draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError }, } diff --git a/packages/contracts/generated/api/console/errors.gen.ts b/packages/contracts/generated/api/console/errors.gen.ts index 34e0844610ffc4..e3098084a0a65e 100644 --- a/packages/contracts/generated/api/console/errors.gen.ts +++ b/packages/contracts/generated/api/console/errors.gen.ts @@ -1,11 +1,8 @@ // This file is auto-generated by scripts/add-orpc-error-maps.ts export { delete10Errors as 'DELETE /apps/{app_id}/workflow/comments/{comment_id}' } from './apps/errors.gen.ts' export { delete10Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/research-tasks/{task_id}' } from './knowledge-fs/errors.gen.ts' -export { delete11Errors as 'DELETE /apps/{app_id}/workflows/draft/nodes/{node_id}/variables' } from './apps/errors.gen.ts' export { delete11Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/sources/{source_id}' } from './knowledge-fs/errors.gen.ts' -export { delete12Errors as 'DELETE /apps/{app_id}/workflows/draft/variables/{variable_id}' } from './apps/errors.gen.ts' export { delete12Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}' } from './knowledge-fs/errors.gen.ts' -export { delete13Errors as 'DELETE /apps/{app_id}/workflows/draft/variables' } from './apps/errors.gen.ts' export { delete13Errors as 'DELETE /knowledge-fs/uploads/{upload_id}' } from './knowledge-fs/errors.gen.ts' export { delete14Errors as 'DELETE /apps/{app_id}/workflows/{workflow_id}' } from './apps/errors.gen.ts' export { delete16Errors as 'DELETE /workspaces/current/skills/{skill_id}' } from './workspaces/errors.gen.ts' @@ -14,15 +11,12 @@ export { delete2Errors as 'DELETE /apps/{app_id}/agent/config/skills/{name}' } f export { delete2Errors as 'DELETE /datasets/external-knowledge-api/{external_knowledge_api_id}' } from './datasets/errors.gen.ts' export { delete2Errors as 'DELETE /installed-apps/{installed_app_id}/saved-messages/{message_id}' } from './installed-apps/errors.gen.ts' export { delete2Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/app-bindings/{caller_kind}/{app_id}' } from './knowledge-fs/errors.gen.ts' -export { delete2Errors as 'DELETE /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/variables' } from './rag/errors.gen.ts' export { delete3Errors as 'DELETE /agent/{agent_id}/config/files/{name}' } from './agent/errors.gen.ts' export { delete3Errors as 'DELETE /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id}' } from './datasets/errors.gen.ts' export { delete3Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/documents/bulk' } from './knowledge-fs/errors.gen.ts' -export { delete3Errors as 'DELETE /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id}' } from './rag/errors.gen.ts' export { delete4Errors as 'DELETE /agent/{agent_id}/config/skills/{name}' } from './agent/errors.gen.ts' export { delete4Errors as 'DELETE /apps/{app_id}/annotations' } from './apps/errors.gen.ts' export { delete4Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/documents/{document_id}' } from './knowledge-fs/errors.gen.ts' -export { delete4Errors as 'DELETE /rag/pipelines/{pipeline_id}/workflows/draft/variables' } from './rag/errors.gen.ts' export { delete4Errors as 'DELETE /workspaces/current/members/{member_id}' } from './workspaces/errors.gen.ts' export { delete5Errors as 'DELETE /agent/{agent_id}/trace-config' } from './agent/errors.gen.ts' export { delete5Errors as 'DELETE /apps/{app_id}/chat-conversations/{conversation_id}' } from './apps/errors.gen.ts' @@ -63,7 +57,6 @@ export { get13Errors as 'GET /trial-apps/{app_id}/messages/{message_id}/suggeste export { get14Errors as 'GET /agent/{agent_id}/config/skills/{name}/files/content' } from './agent/errors.gen.ts' export { get14Errors as 'GET /apps/{app_id}/agent/config/skills/{name}/inspect' } from './apps/errors.gen.ts' export { get14Errors as 'GET /knowledge-fs/spaces/{control_space_id}/documents/{document_id}/revisions' } from './knowledge-fs/errors.gen.ts' -export { get14Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/variables' } from './rag/errors.gen.ts' export { get14Errors as 'GET /snippets/{snippet_id}/workflows/draft/variables' } from './snippets/errors.gen.ts' export { get14Errors as 'GET /trial-apps/{app_id}/parameters' } from './trial-apps/errors.gen.ts' export { get15Errors as 'GET /agent/{agent_id}/config/skills/{name}/files/download' } from './agent/errors.gen.ts' @@ -73,19 +66,16 @@ export { get15Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/pre-pr export { get15Errors as 'GET /snippets/{snippet_id}/workflows/draft' } from './snippets/errors.gen.ts' export { get15Errors as 'GET /trial-apps/{app_id}/site' } from './trial-apps/errors.gen.ts' export { get16Errors as 'GET /agent/{agent_id}/config/skills/{name}/files/preview' } from './agent/errors.gen.ts' -export { get16Errors as 'GET /apps/{app_id}/agent/logs' } from './apps/errors.gen.ts' export { get16Errors as 'GET /datasets/{dataset_id}/documents/{document_id}/indexing-estimate' } from './datasets/errors.gen.ts' export { get16Errors as 'GET /knowledge-fs/spaces/{control_space_id}/documents' } from './knowledge-fs/errors.gen.ts' export { get16Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/processing/parameters' } from './rag/errors.gen.ts' export { get16Errors as 'GET /trial-apps/{app_id}/workflows' } from './trial-apps/errors.gen.ts' export { get17Errors as 'GET /agent/{agent_id}/config/skills/{name}/inspect' } from './agent/errors.gen.ts' export { get17Errors as 'GET /knowledge-fs/spaces/{control_space_id}/external-access' } from './knowledge-fs/errors.gen.ts' -export { get17Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/system-variables' } from './rag/errors.gen.ts' export { get17Errors as 'GET /trial-apps/{app_id}' } from './trial-apps/errors.gen.ts' export { get18Errors as 'GET /agent/{agent_id}/config/skills' } from './agent/errors.gen.ts' export { get18Errors as 'GET /datasets/{dataset_id}/documents/{document_id}/notion/sync' } from './datasets/errors.gen.ts' export { get18Errors as 'GET /knowledge-fs/spaces/{control_space_id}/golden-questions' } from './knowledge-fs/errors.gen.ts' -export { get18Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id}' } from './rag/errors.gen.ts' export { get19Errors as 'GET /agent/{agent_id}/log-sources' } from './agent/errors.gen.ts' export { get19Errors as 'GET /knowledge-fs/spaces/{control_space_id}/jobs/{job_id}' } from './knowledge-fs/errors.gen.ts' export { get19Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/variables' } from './rag/errors.gen.ts' @@ -228,7 +218,6 @@ export { get63Errors as 'GET /apps/{app_id}/workflow/statistics/daily-terminals' export { get64Errors as 'GET /apps/{app_id}/workflow/statistics/token-costs' } from './apps/errors.gen.ts' export { get65Errors as 'GET /apps/{app_id}/workflows/default-workflow-block-configs/{block_type}' } from './apps/errors.gen.ts' export { get66Errors as 'GET /apps/{app_id}/workflows/default-workflow-block-configs' } from './apps/errors.gen.ts' -export { get67Errors as 'GET /apps/{app_id}/workflows/draft/conversation-variables' } from './apps/errors.gen.ts' export { get68Errors as 'GET /apps/{app_id}/workflows/draft/environment-variables' } from './apps/errors.gen.ts' export { get69Errors as 'GET /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/candidates' } from './apps/errors.gen.ts' export { get6Errors as 'GET /agent/{agent_id}/chat-messages' } from './agent/errors.gen.ts' @@ -239,13 +228,10 @@ export { get6Errors as 'GET /rag/pipelines/{pipeline_id}/exports' } from './rag/ export { get6Errors as 'GET /trial-apps/{app_id}/agent/config/skills/{name}/files/content' } from './trial-apps/errors.gen.ts' export { get70Errors as 'GET /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer' } from './apps/errors.gen.ts' export { get71Errors as 'GET /apps/{app_id}/workflows/draft/nodes/{node_id}/last-run' } from './apps/errors.gen.ts' -export { get72Errors as 'GET /apps/{app_id}/workflows/draft/nodes/{node_id}/variables' } from './apps/errors.gen.ts' export { get73Errors as 'GET /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs/events' } from './apps/errors.gen.ts' export { get74Errors as 'GET /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs/{node_id}/{output_name}/preview' } from './apps/errors.gen.ts' export { get75Errors as 'GET /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs/{node_id}' } from './apps/errors.gen.ts' export { get76Errors as 'GET /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs' } from './apps/errors.gen.ts' -export { get77Errors as 'GET /apps/{app_id}/workflows/draft/system-variables' } from './apps/errors.gen.ts' -export { get78Errors as 'GET /apps/{app_id}/workflows/draft/variables/{variable_id}' } from './apps/errors.gen.ts' export { get79Errors as 'GET /apps/{app_id}/workflows/draft/variables' } from './apps/errors.gen.ts' export { get7Errors as 'GET /account/profile' } from './account/errors.gen.ts' export { get7Errors as 'GET /agent/{agent_id}/composer/candidates' } from './agent/errors.gen.ts' @@ -287,11 +273,9 @@ export { getErrors as 'GET /installed-apps/{installed_app_id}/conversations' } f export { getErrors as 'GET /remote-files/{url}' } from './remote-files/errors.gen.ts' export { getErrors as 'GET /trial-apps/{app_id}/agent-composer' } from './trial-apps/errors.gen.ts' export { getErrors as 'GET /website/crawl/status/{job_id}' } from './website/errors.gen.ts' -export { patch2Errors as 'PATCH /apps/{app_id}/workflows/draft/variables/{variable_id}' } from './apps/errors.gen.ts' export { patch2Errors as 'PATCH /datasets/{dataset_id}/documents/status/{action}/batch' } from './datasets/errors.gen.ts' export { patch2Errors as 'PATCH /installed-apps/{installed_app_id}/conversations/{c_id}/unpin' } from './installed-apps/errors.gen.ts' export { patch2Errors as 'PATCH /knowledge-fs/spaces/{control_space_id}/golden-questions/{question_id}' } from './knowledge-fs/errors.gen.ts' -export { patch2Errors as 'PATCH /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id}' } from './rag/errors.gen.ts' export { patch3Errors as 'PATCH /apps/{app_id}/workflows/{workflow_id}' } from './apps/errors.gen.ts' export { patch3Errors as 'PATCH /datasets/{dataset_id}/documents/{document_id}/processing/pause' } from './datasets/errors.gen.ts' export { patch3Errors as 'PATCH /installed-apps/{installed_app_id}' } from './installed-apps/errors.gen.ts' @@ -452,9 +436,7 @@ export { post42Errors as 'POST /apps/{app_id}/workflow/comments/{comment_id}/res export { post42Errors as 'POST /knowledge-fs/uploads' } from './knowledge-fs/errors.gen.ts' export { post43Errors as 'POST /apps/{app_id}/workflow/comments' } from './apps/errors.gen.ts' export { post43Errors as 'POST /workspaces/current/plugin/upload/github' } from './workspaces/errors.gen.ts' -export { post44Errors as 'POST /apps/{app_id}/workflows/draft/conversation-variables' } from './apps/errors.gen.ts' export { post44Errors as 'POST /workspaces/current/plugin/upload/pkg' } from './workspaces/errors.gen.ts' -export { post45Errors as 'POST /apps/{app_id}/workflows/draft/environment-variables' } from './apps/errors.gen.ts' export { post46Errors as 'POST /apps/{app_id}/workflows/draft/features' } from './apps/errors.gen.ts' export { post47Errors as 'POST /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/delivery-test' } from './apps/errors.gen.ts' export { post48Errors as 'POST /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/form/preview' } from './apps/errors.gen.ts' @@ -559,10 +541,8 @@ export { put4Errors as 'PUT /apps/{app_id}/workflow/comments/{comment_id}' } fro export { put4Errors as 'PUT /knowledge-fs/spaces/{control_space_id}/sources/{source_id}/sync-policy' } from './knowledge-fs/errors.gen.ts' export { put5Errors as 'PUT /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer' } from './apps/errors.gen.ts' export { put5Errors as 'PUT /knowledge-fs/spaces/{control_space_id}/tags' } from './knowledge-fs/errors.gen.ts' -export { put6Errors as 'PUT /apps/{app_id}/workflows/draft/variables/{variable_id}/reset' } from './apps/errors.gen.ts' export { putErrors as 'PUT /agent/{agent_id}/build-draft' } from './agent/errors.gen.ts' export { putErrors as 'PUT /apps/{app_id}/network-access-group' } from './apps/errors.gen.ts' export { putErrors as 'PUT /knowledge-fs/spaces/{control_space_id}/app-bindings' } from './knowledge-fs/errors.gen.ts' -export { putErrors as 'PUT /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id}/reset' } from './rag/errors.gen.ts' export { putErrors as 'PUT /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer' } from './snippets/errors.gen.ts' export { putErrors as 'PUT /workspaces/current/agents/{agent_id}/skills' } from './workspaces/errors.gen.ts' diff --git a/packages/contracts/generated/api/console/rag/errors.gen.ts b/packages/contracts/generated/api/console/rag/errors.gen.ts index f87498e70afbc6..501d339d7f2b47 100644 --- a/packages/contracts/generated/api/console/rag/errors.gen.ts +++ b/packages/contracts/generated/api/console/rag/errors.gen.ts @@ -78,7 +78,6 @@ export const post10Errors = { pipeline_not_found: { status: 404, data: zPipeline export const get12Errors = { draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError }, - pipeline_not_found: { status: 404, data: zPipelineNotFoundError }, } export const post11Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } @@ -89,10 +88,6 @@ export const get13Errors = { pipeline_not_found: { status: 404, data: zPipelineN export const post13Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } -export const delete2Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - -export const get14Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - export const get15Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } export const get16Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } @@ -102,21 +97,8 @@ export const post14Errors = { rate_limit_error: { status: 429, data: zRateLimitError }, } -export const get17Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - -export const putErrors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - -export const delete3Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - -export const get18Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - -export const patch2Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - -export const delete4Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - export const get19Errors = { draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError }, - pipeline_not_found: { status: 404, data: zPipelineNotFoundError }, } export const get20Errors = {