mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
feat: enhance go to anything (#32130)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
autofix-ci[bot]
Copilot Autofix powered by AI
parent
c8abb11bf0
commit
0bfbd2061e
@@ -254,3 +254,208 @@ def test_instruction_template_invalid_type(app) -> None:
|
||||
):
|
||||
with pytest.raises(ValueError):
|
||||
method()
|
||||
|
||||
|
||||
# ─ /workflow-generate ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _workflow_generate_payload() -> dict:
|
||||
return {
|
||||
"mode": "workflow",
|
||||
"instruction": "Summarize a URL",
|
||||
"ideal_output": "A 3-sentence summary.",
|
||||
"model_config": _model_config_payload(),
|
||||
}
|
||||
|
||||
|
||||
def _stub_workflow_service(monkeypatch: pytest.MonkeyPatch, returns=None, raises: Exception | None = None):
|
||||
def _call(**_kwargs):
|
||||
if raises is not None:
|
||||
raise raises
|
||||
return returns or {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph", _call)
|
||||
|
||||
|
||||
def test_workflow_generate_returns_service_result(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
expected = {
|
||||
"graph": {"nodes": [{"id": "node-1"}], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "Summarize",
|
||||
"error": "",
|
||||
}
|
||||
_stub_workflow_service(monkeypatch, returns=expected)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=_workflow_generate_payload(),
|
||||
):
|
||||
response = method("t1")
|
||||
|
||||
assert response == expected
|
||||
|
||||
|
||||
def test_workflow_generate_maps_provider_token_error(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""ProviderTokenNotInitError → ProviderNotInitializeError so the frontend
|
||||
can render the same "provider missing" UX as /rule-generate."""
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
_stub_workflow_service(monkeypatch, raises=ProviderTokenNotInitError("missing token"))
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=_workflow_generate_payload(),
|
||||
):
|
||||
with pytest.raises(ProviderNotInitializeError):
|
||||
method("t1")
|
||||
|
||||
|
||||
def test_workflow_generate_maps_quota_error(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from controllers.console.app.error import ProviderQuotaExceededError
|
||||
from core.errors.error import QuotaExceededError
|
||||
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
_stub_workflow_service(monkeypatch, raises=QuotaExceededError())
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=_workflow_generate_payload(),
|
||||
):
|
||||
with pytest.raises(ProviderQuotaExceededError):
|
||||
method("t1")
|
||||
|
||||
|
||||
def test_workflow_generate_maps_model_not_support_error(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from controllers.console.app.error import ProviderModelCurrentlyNotSupportError
|
||||
from core.errors.error import ModelCurrentlyNotSupportError
|
||||
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
_stub_workflow_service(monkeypatch, raises=ModelCurrentlyNotSupportError("not supported"))
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=_workflow_generate_payload(),
|
||||
):
|
||||
with pytest.raises(ProviderModelCurrentlyNotSupportError):
|
||||
method("t1")
|
||||
|
||||
|
||||
def test_workflow_generate_maps_invoke_error(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
from controllers.console.app.error import CompletionRequestError
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
_stub_workflow_service(monkeypatch, raises=InvokeError("LLM unreachable"))
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=_workflow_generate_payload(),
|
||||
):
|
||||
with pytest.raises(CompletionRequestError):
|
||||
method("t1")
|
||||
|
||||
|
||||
def test_workflow_generate_accepts_advanced_chat_mode(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""The payload Literal must accept advanced-chat as well as workflow."""
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _capture(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph", _capture)
|
||||
|
||||
payload = _workflow_generate_payload()
|
||||
payload["mode"] = "advanced-chat"
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=payload,
|
||||
):
|
||||
method("t1")
|
||||
|
||||
assert captured["mode"] == "advanced-chat"
|
||||
assert captured["instruction"] == "Summarize a URL"
|
||||
assert captured["ideal_output"] == "A 3-sentence summary."
|
||||
|
||||
|
||||
def test_workflow_generate_forwards_current_graph_for_refine(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""cmd+k `/refine`: the optional current_graph field reaches the service."""
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _capture(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph", _capture)
|
||||
|
||||
graph = {"nodes": [{"id": "node1"}], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}}
|
||||
payload = _workflow_generate_payload()
|
||||
payload["current_graph"] = graph
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=payload,
|
||||
):
|
||||
method("t1")
|
||||
|
||||
assert captured["current_graph"] == graph
|
||||
|
||||
|
||||
def test_workflow_generate_current_graph_defaults_to_none(app, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Omitting current_graph (the `/create` path) forwards None to the service."""
|
||||
api = generator_module.WorkflowGenerateApi()
|
||||
method = _unwrap(api.post)
|
||||
|
||||
captured: dict = {}
|
||||
|
||||
def _capture(**kwargs):
|
||||
captured.update(kwargs)
|
||||
return {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph", _capture)
|
||||
|
||||
with app.test_request_context(
|
||||
"/console/api/workflow-generate",
|
||||
method="POST",
|
||||
json=_workflow_generate_payload(),
|
||||
):
|
||||
method("t1")
|
||||
|
||||
assert captured["current_graph"] is None
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Unit tests for the planner / builder prompt format helpers.
|
||||
|
||||
These helpers are pure string-shaping functions that wrap conditional sections
|
||||
into the LLM prompts. We assert they (1) emit empty strings when the source
|
||||
data is empty so the prompt stays tight, (2) include the relevant header text
|
||||
when data is present, and (3) round-trip the raw catalogue text unchanged.
|
||||
"""
|
||||
|
||||
from core.workflow.generator.prompts.builder_prompts import (
|
||||
BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT,
|
||||
BUILDER_SYSTEM_PROMPT_WORKFLOW,
|
||||
format_builder_tool_catalogue_section,
|
||||
format_plan_block,
|
||||
get_builder_system_prompt,
|
||||
)
|
||||
from core.workflow.generator.prompts.planner_prompts import (
|
||||
format_ideal_output_section,
|
||||
format_tool_catalogue_section,
|
||||
)
|
||||
|
||||
|
||||
class TestFormatIdealOutputSection:
|
||||
def test_returns_empty_string_for_blank_input(self):
|
||||
assert format_ideal_output_section("") == ""
|
||||
assert format_ideal_output_section(" \n\t ") == ""
|
||||
|
||||
def test_wraps_content_in_a_labelled_section(self):
|
||||
out = format_ideal_output_section("A short summary.")
|
||||
assert out.startswith("# Ideal output")
|
||||
assert "A short summary." in out
|
||||
assert out.endswith("\n\n")
|
||||
|
||||
|
||||
class TestPlannerCatalogueSection:
|
||||
def test_returns_empty_when_catalogue_is_blank(self):
|
||||
# No installed tools — the planner shouldn't see an "Available tools"
|
||||
# heading at all; an empty string keeps the prompt tight.
|
||||
assert format_tool_catalogue_section("") == ""
|
||||
assert format_tool_catalogue_section(" ") == ""
|
||||
|
||||
def test_emits_a_planner_facing_header_with_the_catalogue(self):
|
||||
out = format_tool_catalogue_section("- google/search — Search.")
|
||||
assert "# Available tools" in out
|
||||
assert "planner" in out.lower()
|
||||
assert "- google/search — Search." in out
|
||||
|
||||
|
||||
class TestBuilderCatalogueSection:
|
||||
def test_returns_empty_when_catalogue_is_blank(self):
|
||||
assert format_builder_tool_catalogue_section("") == ""
|
||||
|
||||
def test_includes_strict_provider_tool_guidance(self):
|
||||
out = format_builder_tool_catalogue_section("- google/search — Search.")
|
||||
# The builder must be told to use the *exact* identifiers — hallucinated
|
||||
# tools fail at sync time.
|
||||
assert "exact" in out.lower()
|
||||
assert "provider_id" in out
|
||||
assert "tool_name" in out
|
||||
assert "- google/search — Search." in out
|
||||
|
||||
|
||||
class TestFormatPlanBlock:
|
||||
def test_renders_one_line_per_node(self):
|
||||
out = format_plan_block(
|
||||
[
|
||||
{"label": "Start", "node_type": "start", "purpose": "Take input"},
|
||||
{"label": "Summarize", "node_type": "llm", "purpose": "Summarize"},
|
||||
]
|
||||
)
|
||||
lines = out.split("\n")
|
||||
# Two nodes → 4 lines (each entry takes id-line + purpose-line).
|
||||
assert any(line.startswith("1.") and "node1" in line for line in lines)
|
||||
assert any(line.startswith("2.") and "node2" in line for line in lines)
|
||||
assert "purpose: Take input" in out
|
||||
assert "purpose: Summarize" in out
|
||||
|
||||
def test_handles_missing_fields_gracefully(self):
|
||||
out = format_plan_block([{"node_type": "llm"}])
|
||||
# Missing label/purpose must not raise — they degrade to empty strings.
|
||||
assert "node1" in out
|
||||
assert "type=llm" in out
|
||||
|
||||
|
||||
class TestGetBuilderSystemPrompt:
|
||||
def test_returns_workflow_prompt_for_workflow_mode(self):
|
||||
# The two prompts are structurally similar but differ in their
|
||||
# mode-specific rules block.
|
||||
prompt = get_builder_system_prompt("workflow")
|
||||
assert prompt is BUILDER_SYSTEM_PROMPT_WORKFLOW
|
||||
assert 'exactly one "end" node' in prompt
|
||||
|
||||
def test_returns_advanced_chat_prompt_for_advanced_chat_mode(self):
|
||||
prompt = get_builder_system_prompt("advanced-chat")
|
||||
assert prompt is BUILDER_SYSTEM_PROMPT_ADVANCED_CHAT
|
||||
assert 'exactly one "answer" node' in prompt
|
||||
|
||||
|
||||
class TestFormatPlanBlockParentHints:
|
||||
def test_resolves_parent_label_to_node_id(self):
|
||||
# The planner emits parent="Per Item" as a hint; the builder needs the
|
||||
# resolved id ("node-N") to set parentId on the inner node.
|
||||
from core.workflow.generator.prompts.builder_prompts import format_plan_block
|
||||
|
||||
out = format_plan_block(
|
||||
[
|
||||
{"label": "Start", "node_type": "start", "purpose": "x"},
|
||||
{"label": "Per Item", "node_type": "iteration", "purpose": "iterate"},
|
||||
{"label": "Sum Item", "node_type": "llm", "purpose": "summarize one", "parent": "Per Item"},
|
||||
]
|
||||
)
|
||||
# The inner line should mention parent=node2 (the iteration node).
|
||||
assert "parent=node2" in out
|
||||
# Top-level nodes must not have a parent clause.
|
||||
first_line = out.splitlines()[0]
|
||||
assert "parent=" not in first_line
|
||||
|
||||
def test_omits_parent_clause_when_label_is_unknown(self):
|
||||
# A typo / unknown parent label should degrade to quoting the raw
|
||||
# label string rather than fabricating a node id.
|
||||
from core.workflow.generator.prompts.builder_prompts import format_plan_block
|
||||
|
||||
out = format_plan_block(
|
||||
[
|
||||
{"label": "Start", "node_type": "start", "purpose": "x"},
|
||||
{"label": "Step", "node_type": "code", "purpose": "x", "parent": "Ghost Container"},
|
||||
]
|
||||
)
|
||||
assert "parent='Ghost Container'" in out
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,349 @@
|
||||
"""Unit tests for the tool catalogue helpers."""
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
from core.workflow.generator.tool_catalogue import (
|
||||
ToolCatalogueEntry,
|
||||
_i18n_text,
|
||||
_tool_description,
|
||||
build_tool_catalogue,
|
||||
format_tool_catalogue,
|
||||
installed_tool_keys,
|
||||
)
|
||||
|
||||
|
||||
def _entry(provider: str, tool: str, *, label: str = "", description: str = "") -> ToolCatalogueEntry:
|
||||
return ToolCatalogueEntry(
|
||||
provider_name=provider,
|
||||
provider_type="builtin",
|
||||
plugin_id="",
|
||||
tool_name=tool,
|
||||
tool_label=label,
|
||||
description=description,
|
||||
)
|
||||
|
||||
|
||||
class TestInstalledToolKeys:
|
||||
"""The validator in ``runner.py`` looks up tool nodes against this set.
|
||||
|
||||
Keys MUST be ``(provider_name, tool_name)`` tuples — the builder prompt
|
||||
is instructed to put ``provider_name`` into both ``data.provider_id``
|
||||
and ``data.provider_name`` on tool nodes, so the runner's check accepts
|
||||
either field. The set therefore keys on ``provider_name``, not
|
||||
``plugin_id`` or any other identifier.
|
||||
"""
|
||||
|
||||
def test_empty_input_returns_empty_set(self):
|
||||
assert installed_tool_keys([]) == set()
|
||||
|
||||
def test_returns_provider_tool_tuples(self):
|
||||
keys = installed_tool_keys(
|
||||
[
|
||||
_entry("google", "search"),
|
||||
_entry("github", "list_issues"),
|
||||
]
|
||||
)
|
||||
assert keys == {("google", "search"), ("github", "list_issues")}
|
||||
|
||||
def test_dedupes_duplicate_entries(self):
|
||||
# Defensive — the catalogue builder dedupes on read, but a duplicate
|
||||
# entry slipping through should collapse rather than break the set
|
||||
# type contract.
|
||||
keys = installed_tool_keys([_entry("x", "y"), _entry("x", "y")])
|
||||
assert keys == {("x", "y")}
|
||||
|
||||
|
||||
class TestFormatToolCatalogue:
|
||||
def test_empty_input_returns_empty_string(self):
|
||||
assert format_tool_catalogue([]) == ""
|
||||
|
||||
def test_renders_provider_slash_tool_per_line(self):
|
||||
out = format_tool_catalogue(
|
||||
[
|
||||
_entry("google", "search", description="Search the web with Google."),
|
||||
_entry("time", "current_time", description="Return the current time."),
|
||||
]
|
||||
)
|
||||
lines = out.split("\n")
|
||||
assert lines == [
|
||||
"- google/search — Search the web with Google.",
|
||||
"- time/current_time — Return the current time.",
|
||||
]
|
||||
|
||||
def test_includes_label_when_different_from_tool_name(self):
|
||||
out = format_tool_catalogue(
|
||||
[
|
||||
_entry("google", "search", label="Google Search", description="Search."),
|
||||
]
|
||||
)
|
||||
assert out == "- google/search (Google Search) — Search."
|
||||
|
||||
def test_omits_label_when_identical_to_tool_name(self):
|
||||
out = format_tool_catalogue(
|
||||
[
|
||||
_entry("time", "current_time", label="current_time", description="Now."),
|
||||
]
|
||||
)
|
||||
assert out == "- time/current_time — Now."
|
||||
|
||||
def test_truncates_long_descriptions(self):
|
||||
long_desc = "x" * 200
|
||||
out = format_tool_catalogue([_entry("p", "t", description=long_desc)])
|
||||
# Truncated to 117 chars + "..."
|
||||
assert out.endswith("...")
|
||||
assert len(out.split(" — ", 1)[1]) == 120
|
||||
|
||||
def test_strips_newlines_from_descriptions(self):
|
||||
out = format_tool_catalogue([_entry("p", "t", description="line1\nline2\nline3")])
|
||||
assert "\n" not in out.split(" — ", 1)[1]
|
||||
assert "line1 line2 line3" in out
|
||||
|
||||
|
||||
# ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class _FakeI18n(SimpleNamespace):
|
||||
"""Minimal stand-in for ``I18nObject`` — only the attrs we read."""
|
||||
|
||||
|
||||
class _FakeToolEntity(SimpleNamespace):
|
||||
"""Tool entity exposing ``identity`` + ``description`` like the real thing."""
|
||||
|
||||
|
||||
class _FakeToolIdentity(SimpleNamespace):
|
||||
"""Identity holding ``name`` + ``label`` like ``ToolIdentity``."""
|
||||
|
||||
|
||||
class _FakeToolDescription(SimpleNamespace):
|
||||
"""Description with the ``llm`` attribute we read for prompts."""
|
||||
|
||||
|
||||
class _FakeTool:
|
||||
"""Tool stand-in: ``.entity`` is the only attribute the catalogue reads."""
|
||||
|
||||
def __init__(self, entity):
|
||||
self.entity = entity
|
||||
|
||||
|
||||
def _make_tool(name: str, label_en: str = "", description_llm: str = "") -> _FakeTool:
|
||||
return _FakeTool(
|
||||
entity=_FakeToolEntity(
|
||||
identity=_FakeToolIdentity(
|
||||
name=name,
|
||||
label=_FakeI18n(en_US=label_en, zh_Hans=""),
|
||||
),
|
||||
description=_FakeToolDescription(llm=description_llm),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class _FakeProviderType(SimpleNamespace):
|
||||
"""Stand-in for ``ToolProviderType`` — only ``.value`` is read."""
|
||||
|
||||
|
||||
def _make_builtin_provider(name: str, tools: list, raises_on_get_tools: bool = False):
|
||||
"""
|
||||
Build something ``isinstance(..., BuiltinToolProviderController)`` will
|
||||
answer True to without actually constructing one (those require real
|
||||
on-disk plugin metadata). We patch the isinstance call sites instead.
|
||||
"""
|
||||
provider = SimpleNamespace(
|
||||
entity=SimpleNamespace(identity=SimpleNamespace(name=name)),
|
||||
provider_type=_FakeProviderType(value="builtin"),
|
||||
get_tools=((lambda: (_ for _ in ()).throw(RuntimeError("boom"))) if raises_on_get_tools else (lambda: tools)),
|
||||
)
|
||||
provider._is_builtin = True
|
||||
return provider
|
||||
|
||||
|
||||
def _make_plugin_provider(name: str, plugin_id: str, tools: list):
|
||||
provider = SimpleNamespace(
|
||||
entity=SimpleNamespace(identity=SimpleNamespace(name=name)),
|
||||
provider_type=_FakeProviderType(value="plugin"),
|
||||
plugin_id=plugin_id,
|
||||
get_tools=lambda: tools,
|
||||
)
|
||||
provider._is_plugin = True
|
||||
return provider
|
||||
|
||||
|
||||
def _make_unknown_provider(name: str):
|
||||
"""A provider matching neither class — must be skipped."""
|
||||
return SimpleNamespace(
|
||||
entity=SimpleNamespace(identity=SimpleNamespace(name=name)),
|
||||
provider_type=_FakeProviderType(value="weird"),
|
||||
get_tools=lambda: [_make_tool("ghost")],
|
||||
)
|
||||
|
||||
|
||||
def _patched_isinstance(obj, cls):
|
||||
"""
|
||||
Reroute isinstance checks the catalogue uses to the fake providers built
|
||||
above. Anything else falls through to the real isinstance.
|
||||
"""
|
||||
from core.tools.builtin_tool.provider import BuiltinToolProviderController
|
||||
from core.tools.plugin_tool.provider import PluginToolProviderController
|
||||
|
||||
if cls is BuiltinToolProviderController:
|
||||
return bool(getattr(obj, "_is_builtin", False))
|
||||
if cls is PluginToolProviderController:
|
||||
return bool(getattr(obj, "_is_plugin", False))
|
||||
import builtins as _b
|
||||
|
||||
return _b.isinstance(obj, cls)
|
||||
|
||||
|
||||
# ── _i18n_text / _tool_description ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestI18nText:
|
||||
def test_returns_empty_string_when_label_is_none(self):
|
||||
assert _i18n_text(None) == ""
|
||||
|
||||
def test_returns_en_us_when_present(self):
|
||||
assert _i18n_text(_FakeI18n(en_US="Search", zh_Hans="搜索")) == "Search"
|
||||
|
||||
def test_falls_back_to_zh_hans_when_en_us_blank(self):
|
||||
# Some plugins ship only Chinese metadata; falling back keeps the
|
||||
# planner aware of those tools instead of dropping them silently.
|
||||
assert _i18n_text(_FakeI18n(en_US="", zh_Hans="搜索")) == "搜索"
|
||||
|
||||
def test_returns_empty_when_both_locales_missing(self):
|
||||
assert _i18n_text(_FakeI18n()) == ""
|
||||
|
||||
|
||||
class TestToolDescription:
|
||||
def test_returns_empty_string_for_none_description(self):
|
||||
# ToolEntity.description is Optional — must not raise on absent.
|
||||
assert _tool_description(None) == ""
|
||||
|
||||
def test_returns_llm_attribute(self):
|
||||
assert _tool_description(_FakeToolDescription(llm="Web search")) == "Web search"
|
||||
|
||||
def test_returns_empty_when_llm_missing(self):
|
||||
assert _tool_description(SimpleNamespace()) == ""
|
||||
|
||||
|
||||
# ── build_tool_catalogue ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBuildToolCatalogue:
|
||||
"""
|
||||
The builder iterates the ``ToolManager.list_builtin_providers`` generator
|
||||
(which already covers both hardcoded and plugin providers in production).
|
||||
We patch the generator + isinstance so the tests can exercise every branch
|
||||
without standing up real plugin daemon state.
|
||||
"""
|
||||
|
||||
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
|
||||
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
|
||||
def test_returns_empty_list_for_tenant_with_no_tools(self, mock_list, mock_isinstance):
|
||||
mock_list.return_value = iter([])
|
||||
|
||||
assert build_tool_catalogue("tenant-1") == []
|
||||
|
||||
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
|
||||
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
|
||||
def test_collects_hardcoded_and_plugin_tools(self, mock_list, mock_isinstance):
|
||||
# Mixed-tenant scenario: hardcoded provider plus a plugin provider,
|
||||
# each carrying one tool. The catalogue must include all four fields
|
||||
# the workflow tool node will need (provider_name / provider_type /
|
||||
# plugin_id / tool_name).
|
||||
hardcoded = _make_builtin_provider(
|
||||
"time",
|
||||
[_make_tool("current_time", label_en="Current Time", description_llm="Return now.")],
|
||||
)
|
||||
plugin = _make_plugin_provider(
|
||||
"google",
|
||||
plugin_id="langgenius/google",
|
||||
tools=[_make_tool("search", label_en="Google Search", description_llm="Search the web.")],
|
||||
)
|
||||
mock_list.return_value = iter([hardcoded, plugin])
|
||||
|
||||
entries = build_tool_catalogue("tenant-1")
|
||||
|
||||
# Sorted alphabetically by provider_name.
|
||||
assert [(e["provider_name"], e["tool_name"]) for e in entries] == [
|
||||
("google", "search"),
|
||||
("time", "current_time"),
|
||||
]
|
||||
google = entries[0]
|
||||
assert google["provider_type"] == "plugin"
|
||||
assert google["plugin_id"] == "langgenius/google"
|
||||
assert google["tool_label"] == "Google Search"
|
||||
assert google["description"] == "Search the web."
|
||||
time_entry = entries[1]
|
||||
assert time_entry["provider_type"] == "builtin"
|
||||
assert time_entry["plugin_id"] == ""
|
||||
|
||||
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
|
||||
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
|
||||
def test_skips_unknown_provider_classes(self, mock_list, mock_isinstance):
|
||||
# If ToolManager ever yields a provider the catalogue doesn't know how
|
||||
# to label, we must continue (not raise) and leave it out of the
|
||||
# output rather than guessing at provider_type.
|
||||
unknown = _make_unknown_provider("mystery")
|
||||
hardcoded = _make_builtin_provider("time", [_make_tool("now")])
|
||||
mock_list.return_value = iter([unknown, hardcoded])
|
||||
|
||||
entries = build_tool_catalogue("tenant-1")
|
||||
|
||||
assert [e["provider_name"] for e in entries] == ["time"]
|
||||
|
||||
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
|
||||
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
|
||||
def test_continues_when_a_provider_get_tools_raises(self, mock_list, mock_isinstance):
|
||||
# A buggy plugin must not break the whole catalogue. Resilient
|
||||
# per-provider try/except is what keeps generation usable in tenants
|
||||
# with broken installs.
|
||||
bad = _make_builtin_provider("broken", [], raises_on_get_tools=True)
|
||||
good = _make_builtin_provider("time", [_make_tool("now")])
|
||||
mock_list.return_value = iter([bad, good])
|
||||
|
||||
entries = build_tool_catalogue("tenant-1")
|
||||
|
||||
assert [e["provider_name"] for e in entries] == ["time"]
|
||||
|
||||
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
|
||||
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
|
||||
def test_skips_individual_tools_when_their_metadata_is_broken(self, mock_list, mock_isinstance):
|
||||
# Per-tool try/except — a single mis-declared tool inside an otherwise
|
||||
# healthy provider gets dropped, the rest still surface.
|
||||
good_tool = _make_tool("ok", label_en="Ok", description_llm="Healthy tool.")
|
||||
# Bad tool: accessing .entity.identity raises because entity is None.
|
||||
bad_tool = SimpleNamespace(entity=None)
|
||||
hardcoded = _make_builtin_provider("p", [bad_tool, good_tool])
|
||||
mock_list.return_value = iter([hardcoded])
|
||||
|
||||
entries = build_tool_catalogue("tenant-1")
|
||||
|
||||
assert [e["tool_name"] for e in entries] == ["ok"]
|
||||
|
||||
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
|
||||
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
|
||||
def test_truncates_to_max_tools_to_keep_prompt_bounded(self, mock_list, mock_isinstance):
|
||||
# A tenant with hundreds of plugin tools would blow the LLM context
|
||||
# window. The catalogue caps the output at ``_MAX_TOOLS``.
|
||||
big_provider = _make_builtin_provider(
|
||||
"p",
|
||||
[_make_tool(f"t{i:03d}") for i in range(200)],
|
||||
)
|
||||
mock_list.return_value = iter([big_provider])
|
||||
|
||||
entries = build_tool_catalogue("tenant-1")
|
||||
|
||||
assert len(entries) == 80
|
||||
|
||||
@patch("core.workflow.generator.tool_catalogue.isinstance", side_effect=_patched_isinstance)
|
||||
@patch("core.workflow.generator.tool_catalogue.ToolManager.list_builtin_providers")
|
||||
def test_defaults_plugin_id_to_empty_string_when_missing(self, mock_list, mock_isinstance):
|
||||
# Plugin provider whose plugin_id is None should serialise to "" so
|
||||
# the consumer can safely index ``e["plugin_id"]`` without a None
|
||||
# check at every callsite.
|
||||
plugin = _make_plugin_provider("p", plugin_id=None, tools=[_make_tool("t")])
|
||||
mock_list.return_value = iter([plugin])
|
||||
|
||||
entries = build_tool_catalogue("tenant-1")
|
||||
|
||||
assert entries[0]["plugin_id"] == ""
|
||||
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
Unit tests for ``WorkflowGeneratorService``.
|
||||
|
||||
The service is a thin facade — its job is (1) hand the tenant model_config to
|
||||
``ModelManager`` to get a model_instance, (2) build the tool catalogue, and
|
||||
(3) delegate to ``WorkflowGenerator``. We mock both dependencies so the tests
|
||||
stay fast and focus on the wiring itself.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from core.app.app_config.entities import ModelConfig
|
||||
from graphon.model_runtime.entities.llm_entities import LLMMode
|
||||
from services.workflow_generator_service import WorkflowGeneratorService
|
||||
|
||||
|
||||
def _model_config() -> ModelConfig:
|
||||
return ModelConfig(
|
||||
provider="openai",
|
||||
name="gpt-4o",
|
||||
mode=LLMMode.CHAT,
|
||||
completion_params={"temperature": 0.4},
|
||||
)
|
||||
|
||||
|
||||
class TestWorkflowGeneratorService:
|
||||
@patch("services.workflow_generator_service.WorkflowGenerator")
|
||||
@patch("services.workflow_generator_service.ModelManager")
|
||||
@patch("services.workflow_generator_service.build_tool_catalogue")
|
||||
@patch("services.workflow_generator_service.format_tool_catalogue")
|
||||
def test_forwards_model_instance_and_catalogue_text_to_generator(
|
||||
self,
|
||||
mock_format_catalogue,
|
||||
mock_build_catalogue,
|
||||
mock_model_manager,
|
||||
mock_workflow_generator,
|
||||
):
|
||||
"""Happy path: model_instance + catalogue text + payload flow through."""
|
||||
# Arrange
|
||||
instance = MagicMock(name="model_instance")
|
||||
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = instance
|
||||
mock_build_catalogue.return_value = [{"provider_name": "google"}]
|
||||
mock_format_catalogue.return_value = "- google/search — Search."
|
||||
mock_workflow_generator.generate_workflow_graph.return_value = {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "ok",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
# Act
|
||||
result = WorkflowGeneratorService.generate_workflow_graph(
|
||||
tenant_id="t-1",
|
||||
mode="workflow",
|
||||
instruction="Summarize a URL",
|
||||
model_config=_model_config(),
|
||||
ideal_output="A 3-sentence summary",
|
||||
)
|
||||
|
||||
# Assert
|
||||
mock_model_manager.for_tenant.assert_called_once_with(tenant_id="t-1")
|
||||
mock_workflow_generator.generate_workflow_graph.assert_called_once()
|
||||
call_kwargs = mock_workflow_generator.generate_workflow_graph.call_args.kwargs
|
||||
assert call_kwargs["model_instance"] is instance
|
||||
assert call_kwargs["provider"] == "openai"
|
||||
assert call_kwargs["model_name"] == "gpt-4o"
|
||||
assert call_kwargs["mode"] == "workflow"
|
||||
assert call_kwargs["instruction"] == "Summarize a URL"
|
||||
assert call_kwargs["ideal_output"] == "A 3-sentence summary"
|
||||
assert call_kwargs["tool_catalogue_text"] == "- google/search — Search."
|
||||
assert call_kwargs["model_parameters"] == {"temperature": 0.4}
|
||||
assert result["error"] == ""
|
||||
|
||||
@patch("services.workflow_generator_service.WorkflowGenerator")
|
||||
@patch("services.workflow_generator_service.ModelManager")
|
||||
@patch("services.workflow_generator_service.build_tool_catalogue")
|
||||
def test_catalogue_build_failure_falls_back_to_empty_text(
|
||||
self,
|
||||
mock_build_catalogue,
|
||||
mock_model_manager,
|
||||
mock_workflow_generator,
|
||||
):
|
||||
"""
|
||||
A plugin-daemon outage must not block generation — the catalogue helper
|
||||
is wrapped in try/except so a failure downgrades to an empty catalogue.
|
||||
"""
|
||||
# Arrange
|
||||
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = MagicMock()
|
||||
mock_build_catalogue.side_effect = RuntimeError("plugin daemon unreachable")
|
||||
mock_workflow_generator.generate_workflow_graph.return_value = {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
# Act
|
||||
WorkflowGeneratorService.generate_workflow_graph(
|
||||
tenant_id="t-1",
|
||||
mode="workflow",
|
||||
instruction="Summarize a URL",
|
||||
model_config=_model_config(),
|
||||
)
|
||||
|
||||
# Assert: generation still ran, catalogue text was empty.
|
||||
call_kwargs = mock_workflow_generator.generate_workflow_graph.call_args.kwargs
|
||||
assert call_kwargs["tool_catalogue_text"] == ""
|
||||
|
||||
@patch("services.workflow_generator_service.WorkflowGenerator")
|
||||
@patch("services.workflow_generator_service.ModelManager")
|
||||
@patch("services.workflow_generator_service.build_tool_catalogue")
|
||||
@patch("services.workflow_generator_service.format_tool_catalogue")
|
||||
def test_defaults_ideal_output_to_empty_string(
|
||||
self,
|
||||
mock_format_catalogue,
|
||||
mock_build_catalogue,
|
||||
mock_model_manager,
|
||||
mock_workflow_generator,
|
||||
):
|
||||
"""Callers can omit ideal_output; the runner should still receive ""."""
|
||||
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = MagicMock()
|
||||
mock_build_catalogue.return_value = []
|
||||
mock_format_catalogue.return_value = ""
|
||||
mock_workflow_generator.generate_workflow_graph.return_value = {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
WorkflowGeneratorService.generate_workflow_graph(
|
||||
tenant_id="t-1",
|
||||
mode="advanced-chat",
|
||||
instruction="A chat bot",
|
||||
model_config=_model_config(),
|
||||
)
|
||||
|
||||
call_kwargs = mock_workflow_generator.generate_workflow_graph.call_args.kwargs
|
||||
assert call_kwargs["ideal_output"] == ""
|
||||
assert call_kwargs["mode"] == "advanced-chat"
|
||||
|
||||
@patch("services.workflow_generator_service.WorkflowGenerator")
|
||||
@patch("services.workflow_generator_service.ModelManager")
|
||||
@patch("services.workflow_generator_service.build_tool_catalogue")
|
||||
@patch("services.workflow_generator_service.format_tool_catalogue")
|
||||
def test_forwards_current_graph_for_refine(
|
||||
self,
|
||||
mock_format_catalogue,
|
||||
mock_build_catalogue,
|
||||
mock_model_manager,
|
||||
mock_workflow_generator,
|
||||
):
|
||||
"""The cmd+k `/refine` path passes the existing draft graph through to the runner."""
|
||||
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = MagicMock()
|
||||
mock_build_catalogue.return_value = []
|
||||
mock_format_catalogue.return_value = ""
|
||||
mock_workflow_generator.generate_workflow_graph.return_value = {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
current_graph = {"nodes": [{"id": "node1"}], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}}
|
||||
|
||||
WorkflowGeneratorService.generate_workflow_graph(
|
||||
tenant_id="t-1",
|
||||
mode="workflow",
|
||||
instruction="Add a translation step",
|
||||
model_config=_model_config(),
|
||||
current_graph=current_graph,
|
||||
)
|
||||
|
||||
call_kwargs = mock_workflow_generator.generate_workflow_graph.call_args.kwargs
|
||||
assert call_kwargs["current_graph"] is current_graph
|
||||
|
||||
@patch("services.workflow_generator_service.WorkflowGenerator")
|
||||
@patch("services.workflow_generator_service.ModelManager")
|
||||
@patch("services.workflow_generator_service.build_tool_catalogue")
|
||||
@patch("services.workflow_generator_service.format_tool_catalogue")
|
||||
def test_defaults_current_graph_to_none_for_create(
|
||||
self,
|
||||
mock_format_catalogue,
|
||||
mock_build_catalogue,
|
||||
mock_model_manager,
|
||||
mock_workflow_generator,
|
||||
):
|
||||
"""Omitting current_graph (the `/create` path) forwards None to the runner."""
|
||||
mock_model_manager.for_tenant.return_value.get_model_instance.return_value = MagicMock()
|
||||
mock_build_catalogue.return_value = []
|
||||
mock_format_catalogue.return_value = ""
|
||||
mock_workflow_generator.generate_workflow_graph.return_value = {
|
||||
"graph": {"nodes": [], "edges": [], "viewport": {"x": 0, "y": 0, "zoom": 0.7}},
|
||||
"message": "",
|
||||
"error": "",
|
||||
}
|
||||
|
||||
WorkflowGeneratorService.generate_workflow_graph(
|
||||
tenant_id="t-1",
|
||||
mode="workflow",
|
||||
instruction="Summarize a URL",
|
||||
model_config=_model_config(),
|
||||
)
|
||||
|
||||
call_kwargs = mock_workflow_generator.generate_workflow_graph.call_args.kwargs
|
||||
assert call_kwargs["current_graph"] is None
|
||||
Reference in New Issue
Block a user