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:
Crazywoola
2026-06-04 11:06:17 +00:00
committed by GitHub
co-authored by Claude Opus 4.7 autofix-ci[bot] Copilot Autofix powered by AI
parent c8abb11bf0
commit 0bfbd2061e
48 changed files with 8391 additions and 5 deletions
@@ -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