feat(workflow-generator): enhance the AI auto-creation flow end-to-end (#38175)

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-07-01 02:28:58 +00:00
1 parent 3ad06bebd9
commit 8809cc036d
85 files changed
+3386 -500

No files matched your search

@@ -1,5 +1,6 @@
from __future__ import annotations
import json
from inspect import unwrap
from types import SimpleNamespace
from unittest.mock import MagicMock
@@ -458,3 +459,214 @@ def test_workflow_generate_current_graph_defaults_to_none(app: Flask, monkeypatc
method(api, "t1")
assert captured["current_graph"] is None
def test_workflow_generate_accepts_auto_mode(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
"""Task 3: the payload Literal must accept ``auto``; the controller forwards
it unchanged (the service resolves it) and returns the resolved ``mode``."""
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": "",
"mode": "advanced-chat",
}
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph", _capture)
payload = _workflow_generate_payload()
payload["mode"] = "auto"
with app.test_request_context("/console/api/workflow-generate", method="POST", json=payload):
response = method(api, "t1")
assert captured["mode"] == "auto"
assert response["mode"] == "advanced-chat"
# ─ /workflow-generate/suggestions ─────────────────────────────────────────────
def test_generate_instruction_suggestions_parses_and_cleans(monkeypatch: pytest.MonkeyPatch) -> None:
"""Task 1c (i): a mocked default model returning a JSON array is parsed + cleaned."""
from core.llm_generator import llm_generator as llm_gen_module
instance = MagicMock()
instance.invoke_llm.return_value.message.get_text_content.return_value = '["Summarize a URL", "Translate text"]'
mock_manager = MagicMock()
mock_manager.for_tenant.return_value.get_default_model_instance.return_value = instance
monkeypatch.setattr(llm_gen_module, "ModelManager", mock_manager)
monkeypatch.setattr(llm_gen_module.LLMGenerator, "_build_suggestion_context", staticmethod(lambda _tenant: ""))
result = llm_gen_module.LLMGenerator.generate_workflow_instruction_suggestions(
tenant_id="t1", mode="workflow", count=4
)
assert result == ["Summarize a URL", "Translate text"]
def test_generate_instruction_suggestions_dedupes_and_caps(monkeypatch: pytest.MonkeyPatch) -> None:
"""Whitespace / surrounding quotes are stripped, case-insensitive dupes dropped, capped to count."""
from core.llm_generator import llm_generator as llm_gen_module
instance = MagicMock()
instance.invoke_llm.return_value.message.get_text_content.return_value = (
'[" Summarize a URL ", "summarize a URL", "\'Translate text\'", "Draft an email", "Extra idea"]'
)
mock_manager = MagicMock()
mock_manager.for_tenant.return_value.get_default_model_instance.return_value = instance
monkeypatch.setattr(llm_gen_module, "ModelManager", mock_manager)
monkeypatch.setattr(llm_gen_module.LLMGenerator, "_build_suggestion_context", staticmethod(lambda _tenant: ""))
result = llm_gen_module.LLMGenerator.generate_workflow_instruction_suggestions(
tenant_id="t1", mode="advanced-chat", count=3
)
assert result == ["Summarize a URL", "Translate text", "Draft an email"]
def test_generate_instruction_suggestions_no_default_model_returns_empty(monkeypatch: pytest.MonkeyPatch) -> None:
"""Task 1c (ii): a missing default model degrades to an empty list, never raising."""
from core.llm_generator import llm_generator as llm_gen_module
mock_manager = MagicMock()
mock_manager.for_tenant.return_value.get_default_model_instance.side_effect = ProviderTokenNotInitError(
"no default model"
)
monkeypatch.setattr(llm_gen_module, "ModelManager", mock_manager)
result = llm_gen_module.LLMGenerator.generate_workflow_instruction_suggestions(tenant_id="t1", mode="workflow")
assert result == []
def test_workflow_instruction_suggestions_route_returns_list(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
"""Task 1c (iii): the route wraps the generator output in {"suggestions": [...]}."""
api = generator_module.WorkflowInstructionSuggestionsApi()
method = unwrap(api.post)
captured: dict = {}
def _suggest(**kwargs):
captured.update(kwargs)
return ["Summarize a URL", "Translate text"]
monkeypatch.setattr(generator_module.LLMGenerator, "generate_workflow_instruction_suggestions", _suggest)
with app.test_request_context(
"/console/api/workflow-generate/suggestions",
method="POST",
json={"mode": "workflow", "language": "French", "count": 3},
):
response = method(api, "t1")
assert response == {"suggestions": ["Summarize a URL", "Translate text"]}
assert captured["mode"] == "workflow"
assert captured["language"] == "French"
assert captured["count"] == 3
def test_workflow_instruction_suggestions_route_empty_is_valid_200(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
"""Task 1c (iii): an empty list is a valid soft-fail response."""
api = generator_module.WorkflowInstructionSuggestionsApi()
method = unwrap(api.post)
monkeypatch.setattr(
generator_module.LLMGenerator,
"generate_workflow_instruction_suggestions",
lambda **_kwargs: [],
)
with app.test_request_context(
"/console/api/workflow-generate/suggestions",
method="POST",
json={"mode": "advanced-chat"},
):
response = method(api, "t1")
assert response == {"suggestions": []}
# ─ /workflow-generate/stream ──────────────────────────────────────────────────
def _read_sse_frames(response) -> list[dict]:
"""Decode an SSE Response body into its parsed ``data:`` JSON frames."""
body = response.get_data(as_text=True)
frames = []
for chunk in body.strip().split("\n\n"):
chunk = chunk.strip()
if chunk.startswith("data: "):
frames.append(json.loads(chunk[len("data: ") :]))
return frames
def test_workflow_generate_stream_emits_plan_then_result(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
"""Task 2c: the stream endpoint writes one SSE frame per service event."""
api = generator_module.WorkflowGenerateStreamApi()
method = unwrap(api.post)
def _stream(**_kwargs):
yield ("plan", {"title": "Summarizer", "mode": "workflow", "nodes": []})
yield ("result", {"graph": {"nodes": []}, "error": "", "mode": "workflow"})
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph_stream", _stream)
with app.test_request_context(
"/console/api/workflow-generate/stream",
method="POST",
json=_workflow_generate_payload(),
):
response = method(api, "t1")
assert response.mimetype == "text/event-stream"
frames = _read_sse_frames(response)
assert [f["event"] for f in frames] == ["plan", "result"]
assert frames[0]["title"] == "Summarizer"
assert frames[1]["mode"] == "workflow"
def test_workflow_generate_stream_provider_error_emits_result_event(
app: Flask, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Task 2c: a provider-init error becomes a single MODEL_ERROR result frame, not a non-SSE error."""
api = generator_module.WorkflowGenerateStreamApi()
method = unwrap(api.post)
def _stream(**_kwargs):
raise ProviderTokenNotInitError("missing token")
yield # pragma: no cover - marks this a generator
monkeypatch.setattr(generator_module.WorkflowGeneratorService, "generate_workflow_graph_stream", _stream)
with app.test_request_context(
"/console/api/workflow-generate/stream",
method="POST",
json=_workflow_generate_payload(),
):
response = method(api, "t1")
frames = _read_sse_frames(response)
assert len(frames) == 1
assert frames[0]["event"] == "result"
assert frames[0]["errors"][0]["code"] == "MODEL_ERROR"
assert frames[0]["graph"]["nodes"] == []
def test_workflow_generate_stream_rejects_empty_instruction(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
"""Task 2c: empty instructions get a normal 400 JSON BEFORE the stream opens."""
api = generator_module.WorkflowGenerateStreamApi()
method = unwrap(api.post)
payload = _workflow_generate_payload()
payload["instruction"] = " "
with app.test_request_context("/console/api/workflow-generate/stream", method="POST", json=payload):
response, status = method(api, "t1")
assert status == 400
assert response["errors"][0]["code"] == "EMPTY_INSTRUCTION"
@@ -0,0 +1,138 @@
import pytest
from flask import Flask
from controllers.console.app import generator as generator_module
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from graphon.model_runtime.errors.invoke import InvokeError
def unwrap(func):
"""Unwrap a decorated function to test it directly."""
while hasattr(func, "__wrapped__"):
func = func.__wrapped__
return func
def _model_config_payload():
return {
"provider": "test_provider",
"name": "test_model",
"mode": "chat",
"completion_params": {},
}
def test_rule_generate_exceptions(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
api = generator_module.RuleGenerateApi()
method = unwrap(api.post)
exceptions_to_test = [
(ProviderTokenNotInitError("token error"), generator_module.ProviderNotInitializeError),
(QuotaExceededError("quota error"), generator_module.ProviderQuotaExceededError),
(ModelCurrentlyNotSupportError("model error"), generator_module.ProviderModelCurrentlyNotSupportError),
(InvokeError("invoke error"), generator_module.CompletionRequestError),
]
for err_to_raise, expected_exception in exceptions_to_test:
def _raise(*_args, _err=err_to_raise, **_kwargs):
raise _err
monkeypatch.setattr(generator_module.LLMGenerator, "generate_rule_config", _raise)
with app.test_request_context(
"/console/api/rule-generate",
method="POST",
json={"instruction": "do it", "model_config": _model_config_payload()},
):
with pytest.raises(expected_exception):
method(api, "t1")
def test_rule_code_generate_exceptions(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
api = generator_module.RuleCodeGenerateApi()
method = unwrap(api.post)
exceptions_to_test = [
(QuotaExceededError("quota error"), generator_module.ProviderQuotaExceededError),
(ModelCurrentlyNotSupportError("model error"), generator_module.ProviderModelCurrentlyNotSupportError),
(InvokeError("invoke error"), generator_module.CompletionRequestError),
]
for err_to_raise, expected_exception in exceptions_to_test:
def _raise(*_args, _err=err_to_raise, **_kwargs):
raise _err
monkeypatch.setattr(generator_module.LLMGenerator, "generate_code", _raise)
with app.test_request_context(
"/console/api/rule-code-generate",
method="POST",
json={"instruction": "do it", "model_config": _model_config_payload()},
):
with pytest.raises(expected_exception):
method(api, "t1")
def test_structured_output_generate_exceptions(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
api = generator_module.RuleStructuredOutputGenerateApi()
method = unwrap(api.post)
exceptions_to_test = [
(ProviderTokenNotInitError("token error"), generator_module.ProviderNotInitializeError),
(QuotaExceededError("quota error"), generator_module.ProviderQuotaExceededError),
(ModelCurrentlyNotSupportError("model error"), generator_module.ProviderModelCurrentlyNotSupportError),
(InvokeError("invoke error"), generator_module.CompletionRequestError),
]
for err_to_raise, expected_exception in exceptions_to_test:
def _raise(*_args, _err=err_to_raise, **_kwargs):
raise _err
monkeypatch.setattr(generator_module.LLMGenerator, "generate_structured_output", _raise)
with app.test_request_context(
"/console/api/structured-output-generate",
method="POST",
json={"instruction": "do it", "model_config": _model_config_payload()},
):
with pytest.raises(expected_exception):
method(api, "t1")
def test_instruction_generate_exceptions(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
api = generator_module.InstructionGenerateApi()
method = unwrap(api.post)
from types import SimpleNamespace
session = SimpleNamespace()
exceptions_to_test = [
(ProviderTokenNotInitError("token error"), generator_module.ProviderNotInitializeError),
(QuotaExceededError("quota error"), generator_module.ProviderQuotaExceededError),
(ModelCurrentlyNotSupportError("model error"), generator_module.ProviderModelCurrentlyNotSupportError),
(InvokeError("invoke error"), generator_module.CompletionRequestError),
]
for err_to_raise, expected_exception in exceptions_to_test:
def _raise(*_args, _err=err_to_raise, **_kwargs):
raise _err
monkeypatch.setattr(generator_module.LLMGenerator, "instruction_modify_legacy", _raise)
with app.test_request_context(
"/console/api/instruction-generate",
method="POST",
json={
"flow_id": "app-1",
"node_id": "",
"current": "old",
"instruction": "do it",
"model_config": _model_config_payload(),
},
):
with pytest.raises(expected_exception):
method(api, session, "t1")