From 591414307adf61b47951e6e25e8ff0648ebe4bb7 Mon Sep 17 00:00:00 2001 From: CrabSAMA <40541269+CrabSAMA@users.noreply.github.com> Date: Wed, 26 Nov 2025 14:00:36 +0800 Subject: [PATCH] fix: fixed workflow as tool files field return empty problem (#27925) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: QuantumGhost --- api/core/workflow/nodes/tool/tool_node.py | 10 +- .../workflow/nodes/tool/test_tool_node.py | 160 ++++++++++++++++++ 2 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py diff --git a/api/core/workflow/nodes/tool/tool_node.py b/api/core/workflow/nodes/tool/tool_node.py index 799ad9b92f..4f8dcb92ba 100644 --- a/api/core/workflow/nodes/tool/tool_node.py +++ b/api/core/workflow/nodes/tool/tool_node.py @@ -329,7 +329,15 @@ class ToolNode(Node): json.append(message.message.json_object) elif message.type == ToolInvokeMessage.MessageType.LINK: assert isinstance(message.message, ToolInvokeMessage.TextMessage) - stream_text = f"Link: {message.message.text}\n" + + # Check if this LINK message is a file link + file_obj = (message.meta or {}).get("file") + if isinstance(file_obj, File): + files.append(file_obj) + stream_text = f"File: {message.message.text}\n" + else: + stream_text = f"Link: {message.message.text}\n" + text += stream_text yield StreamChunkEvent( selector=[node_id, "text"], diff --git a/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py new file mode 100644 index 0000000000..1f35c0faed --- /dev/null +++ b/api/tests/unit_tests/core/workflow/nodes/tool/test_tool_node.py @@ -0,0 +1,160 @@ +import sys +import types +from collections.abc import Generator +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock, patch + +import pytest + +from core.file import File, FileTransferMethod, FileType +from core.model_runtime.entities.llm_entities import LLMUsage +from core.tools.entities.tool_entities import ToolInvokeMessage +from core.tools.utils.message_transformer import ToolFileMessageTransformer +from core.variables.segments import ArrayFileSegment +from core.workflow.entities import GraphInitParams +from core.workflow.node_events import StreamChunkEvent, StreamCompletedEvent +from core.workflow.runtime import GraphRuntimeState, VariablePool +from core.workflow.system_variable import SystemVariable + +if TYPE_CHECKING: # pragma: no cover - imported for type checking only + from core.workflow.nodes.tool.tool_node import ToolNode + + +@pytest.fixture +def tool_node(monkeypatch) -> "ToolNode": + module_name = "core.ops.ops_trace_manager" + if module_name not in sys.modules: + ops_stub = types.ModuleType(module_name) + ops_stub.TraceQueueManager = object # pragma: no cover - stub attribute + ops_stub.TraceTask = object # pragma: no cover - stub attribute + monkeypatch.setitem(sys.modules, module_name, ops_stub) + + from core.workflow.nodes.tool.tool_node import ToolNode + + graph_config: dict[str, Any] = { + "nodes": [ + { + "id": "tool-node", + "data": { + "type": "tool", + "title": "Tool", + "desc": "", + "provider_id": "provider", + "provider_type": "builtin", + "provider_name": "provider", + "tool_name": "tool", + "tool_label": "tool", + "tool_configurations": {}, + "tool_parameters": {}, + }, + } + ], + "edges": [], + } + + init_params = GraphInitParams( + tenant_id="tenant-id", + app_id="app-id", + workflow_id="workflow-id", + graph_config=graph_config, + user_id="user-id", + user_from="account", + invoke_from="debugger", + call_depth=0, + ) + + variable_pool = VariablePool(system_variables=SystemVariable(user_id="user-id")) + graph_runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0) + + config = graph_config["nodes"][0] + node = ToolNode( + id="node-instance", + config=config, + graph_init_params=init_params, + graph_runtime_state=graph_runtime_state, + ) + node.init_node_data(config["data"]) + return node + + +def _collect_events(generator: Generator) -> tuple[list[Any], LLMUsage]: + events: list[Any] = [] + try: + while True: + events.append(next(generator)) + except StopIteration as stop: + return events, stop.value + + +def _run_transform(tool_node: "ToolNode", message: ToolInvokeMessage) -> tuple[list[Any], LLMUsage]: + def _identity_transform(messages, *_args, **_kwargs): + return messages + + tool_runtime = MagicMock() + with patch.object(ToolFileMessageTransformer, "transform_tool_invoke_messages", side_effect=_identity_transform): + generator = tool_node._transform_message( + messages=iter([message]), + tool_info={"provider_type": "builtin", "provider_id": "provider"}, + parameters_for_log={}, + user_id="user-id", + tenant_id="tenant-id", + node_id=tool_node._node_id, + tool_runtime=tool_runtime, + ) + return _collect_events(generator) + + +def test_link_messages_with_file_populate_files_output(tool_node: "ToolNode"): + file_obj = File( + tenant_id="tenant-id", + type=FileType.DOCUMENT, + transfer_method=FileTransferMethod.TOOL_FILE, + related_id="file-id", + filename="demo.pdf", + extension=".pdf", + mime_type="application/pdf", + size=123, + storage_key="file-key", + ) + message = ToolInvokeMessage( + type=ToolInvokeMessage.MessageType.LINK, + message=ToolInvokeMessage.TextMessage(text="/files/tools/file-id.pdf"), + meta={"file": file_obj}, + ) + + events, usage = _run_transform(tool_node, message) + + assert isinstance(usage, LLMUsage) + + chunk_events = [event for event in events if isinstance(event, StreamChunkEvent)] + assert chunk_events + assert chunk_events[0].chunk == "File: /files/tools/file-id.pdf\n" + + completed_events = [event for event in events if isinstance(event, StreamCompletedEvent)] + assert len(completed_events) == 1 + outputs = completed_events[0].node_run_result.outputs + assert outputs["text"] == "File: /files/tools/file-id.pdf\n" + + files_segment = outputs["files"] + assert isinstance(files_segment, ArrayFileSegment) + assert files_segment.value == [file_obj] + + +def test_plain_link_messages_remain_links(tool_node: "ToolNode"): + message = ToolInvokeMessage( + type=ToolInvokeMessage.MessageType.LINK, + message=ToolInvokeMessage.TextMessage(text="https://dify.ai"), + meta=None, + ) + + events, _ = _run_transform(tool_node, message) + + chunk_events = [event for event in events if isinstance(event, StreamChunkEvent)] + assert chunk_events + assert chunk_events[0].chunk == "Link: https://dify.ai\n" + + completed_events = [event for event in events if isinstance(event, StreamCompletedEvent)] + assert len(completed_events) == 1 + files_segment = completed_events[0].node_run_result.outputs["files"] + assert isinstance(files_segment, ArrayFileSegment) + assert files_segment.value == []