feat: Human Input Node (#32060)

The frontend and backend implementation for the human input node.

Co-authored-by: twwu <twwu@dify.ai>
Co-authored-by: JzoNg <jzongcode@gmail.com>
Co-authored-by: yyh <92089059+lyzno1@users.noreply.github.com>
Co-authored-by: zhsama <torvalds@linux.do>
This commit is contained in:
QuantumGhost
2026-02-09 14:57:23 +08:00
committed by GitHub
parent 56e3a55023
commit a1fc280102
474 changed files with 32667 additions and 2050 deletions

View File

@ -16,11 +16,9 @@ if not hasattr(builtins, "MethodView"):
builtins.MethodView = MethodView # type: ignore[attr-defined]
def _load_app_module():
@pytest.fixture(scope="module")
def app_module():
module_name = "controllers.console.app.app"
if module_name in sys.modules:
return sys.modules[module_name]
root = Path(__file__).resolve().parents[5]
module_path = root / "controllers" / "console" / "app" / "app.py"
@ -59,8 +57,12 @@ def _load_app_module():
stub_namespace = _StubNamespace()
original_console = sys.modules.get("controllers.console")
original_app_pkg = sys.modules.get("controllers.console.app")
original_modules: dict[str, ModuleType | None] = {
"controllers.console": sys.modules.get("controllers.console"),
"controllers.console.app": sys.modules.get("controllers.console.app"),
"controllers.common.schema": sys.modules.get("controllers.common.schema"),
module_name: sys.modules.get(module_name),
}
stubbed_modules: list[tuple[str, ModuleType | None]] = []
console_module = ModuleType("controllers.console")
@ -105,35 +107,35 @@ def _load_app_module():
module = util.module_from_spec(spec)
sys.modules[module_name] = module
assert spec.loader is not None
spec.loader.exec_module(module)
try:
assert spec.loader is not None
spec.loader.exec_module(module)
yield module
finally:
for name, original in reversed(stubbed_modules):
if original is not None:
sys.modules[name] = original
else:
sys.modules.pop(name, None)
if original_console is not None:
sys.modules["controllers.console"] = original_console
else:
sys.modules.pop("controllers.console", None)
if original_app_pkg is not None:
sys.modules["controllers.console.app"] = original_app_pkg
else:
sys.modules.pop("controllers.console.app", None)
return module
for name, original in original_modules.items():
if original is not None:
sys.modules[name] = original
else:
sys.modules.pop(name, None)
_app_module = _load_app_module()
AppDetailWithSite = _app_module.AppDetailWithSite
AppPagination = _app_module.AppPagination
AppPartial = _app_module.AppPartial
@pytest.fixture(scope="module")
def app_models(app_module):
return SimpleNamespace(
AppDetailWithSite=app_module.AppDetailWithSite,
AppPagination=app_module.AppPagination,
AppPartial=app_module.AppPartial,
)
@pytest.fixture(autouse=True)
def patch_signed_url(monkeypatch):
def patch_signed_url(monkeypatch, app_module):
"""Ensure icon URL generation uses a deterministic helper for tests."""
def _fake_signed_url(key: str | None) -> str | None:
@ -141,7 +143,7 @@ def patch_signed_url(monkeypatch):
return None
return f"signed:{key}"
monkeypatch.setattr(_app_module.file_helpers, "get_signed_file_url", _fake_signed_url)
monkeypatch.setattr(app_module.file_helpers, "get_signed_file_url", _fake_signed_url)
def _ts(hour: int = 12) -> datetime:
@ -169,7 +171,8 @@ def _dummy_workflow():
)
def test_app_partial_serialization_uses_aliases():
def test_app_partial_serialization_uses_aliases(app_models):
AppPartial = app_models.AppPartial
created_at = _ts()
app_obj = SimpleNamespace(
id="app-1",
@ -204,7 +207,8 @@ def test_app_partial_serialization_uses_aliases():
assert serialized["tags"][0]["name"] == "Utilities"
def test_app_detail_with_site_includes_nested_serialization():
def test_app_detail_with_site_includes_nested_serialization(app_models):
AppDetailWithSite = app_models.AppDetailWithSite
timestamp = _ts(14)
site = SimpleNamespace(
code="site-code",
@ -253,7 +257,8 @@ def test_app_detail_with_site_includes_nested_serialization():
assert serialized["site"]["created_at"] == int(timestamp.timestamp())
def test_app_pagination_aliases_per_page_and_has_next():
def test_app_pagination_aliases_per_page_and_has_next(app_models):
AppPagination = app_models.AppPagination
item_one = SimpleNamespace(
id="app-10",
name="Paginated One",

View File

@ -0,0 +1,229 @@
from __future__ import annotations
from dataclasses import dataclass
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from flask import Flask
from pydantic import ValidationError
from controllers.console import wraps as console_wraps
from controllers.console.app import workflow as workflow_module
from controllers.console.app import wraps as app_wraps
from libs import login as login_lib
from models.account import Account, AccountStatus, TenantAccountRole
from models.model import AppMode
def _make_account() -> Account:
account = Account(name="tester", email="tester@example.com")
account.status = AccountStatus.ACTIVE
account.role = TenantAccountRole.OWNER
account.id = "account-123" # type: ignore[assignment]
account._current_tenant = SimpleNamespace(id="tenant-123") # type: ignore[attr-defined]
account._get_current_object = lambda: account # type: ignore[attr-defined]
return account
def _make_app(mode: AppMode) -> SimpleNamespace:
return SimpleNamespace(id="app-123", tenant_id="tenant-123", mode=mode.value)
def _patch_console_guards(monkeypatch: pytest.MonkeyPatch, account: Account, app_model: SimpleNamespace) -> None:
# Skip setup and auth guardrails
monkeypatch.setattr("configs.dify_config.EDITION", "CLOUD")
monkeypatch.setattr(login_lib.dify_config, "LOGIN_DISABLED", True)
monkeypatch.setattr(login_lib, "current_user", account)
monkeypatch.setattr(login_lib, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
monkeypatch.setattr(login_lib, "check_csrf_token", lambda *_, **__: None)
monkeypatch.setattr(console_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
monkeypatch.setattr(app_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
monkeypatch.setattr(workflow_module, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
monkeypatch.setattr(console_wraps.dify_config, "EDITION", "CLOUD")
monkeypatch.delenv("INIT_PASSWORD", raising=False)
# Avoid hitting the database when resolving the app model
monkeypatch.setattr(app_wraps, "_load_app_model", lambda _app_id: app_model)
@dataclass
class PreviewCase:
resource_cls: type
path: str
mode: AppMode
@pytest.mark.parametrize(
"case",
[
PreviewCase(
resource_cls=workflow_module.AdvancedChatDraftHumanInputFormPreviewApi,
path="/console/api/apps/app-123/advanced-chat/workflows/draft/human-input/nodes/node-42/form/preview",
mode=AppMode.ADVANCED_CHAT,
),
PreviewCase(
resource_cls=workflow_module.WorkflowDraftHumanInputFormPreviewApi,
path="/console/api/apps/app-123/workflows/draft/human-input/nodes/node-42/form/preview",
mode=AppMode.WORKFLOW,
),
],
)
def test_human_input_preview_delegates_to_service(
app: Flask, monkeypatch: pytest.MonkeyPatch, case: PreviewCase
) -> None:
account = _make_account()
app_model = _make_app(case.mode)
_patch_console_guards(monkeypatch, account, app_model)
preview_payload = {
"form_id": "node-42",
"form_content": "<div>example</div>",
"inputs": [{"name": "topic"}],
"actions": [{"id": "continue"}],
}
service_instance = MagicMock()
service_instance.get_human_input_form_preview.return_value = preview_payload
monkeypatch.setattr(workflow_module, "WorkflowService", MagicMock(return_value=service_instance))
with app.test_request_context(case.path, method="POST", json={"inputs": {"topic": "tech"}}):
response = case.resource_cls().post(app_id=app_model.id, node_id="node-42")
assert response == preview_payload
service_instance.get_human_input_form_preview.assert_called_once_with(
app_model=app_model,
account=account,
node_id="node-42",
inputs={"topic": "tech"},
)
@dataclass
class SubmitCase:
resource_cls: type
path: str
mode: AppMode
@pytest.mark.parametrize(
"case",
[
SubmitCase(
resource_cls=workflow_module.AdvancedChatDraftHumanInputFormRunApi,
path="/console/api/apps/app-123/advanced-chat/workflows/draft/human-input/nodes/node-99/form/run",
mode=AppMode.ADVANCED_CHAT,
),
SubmitCase(
resource_cls=workflow_module.WorkflowDraftHumanInputFormRunApi,
path="/console/api/apps/app-123/workflows/draft/human-input/nodes/node-99/form/run",
mode=AppMode.WORKFLOW,
),
],
)
def test_human_input_submit_forwards_payload(app: Flask, monkeypatch: pytest.MonkeyPatch, case: SubmitCase) -> None:
account = _make_account()
app_model = _make_app(case.mode)
_patch_console_guards(monkeypatch, account, app_model)
result_payload = {"node_id": "node-99", "outputs": {"__rendered_content": "<p>done</p>"}, "action": "approve"}
service_instance = MagicMock()
service_instance.submit_human_input_form_preview.return_value = result_payload
monkeypatch.setattr(workflow_module, "WorkflowService", MagicMock(return_value=service_instance))
with app.test_request_context(
case.path,
method="POST",
json={"form_inputs": {"answer": "42"}, "inputs": {"#node-1.result#": "LLM output"}, "action": "approve"},
):
response = case.resource_cls().post(app_id=app_model.id, node_id="node-99")
assert response == result_payload
service_instance.submit_human_input_form_preview.assert_called_once_with(
app_model=app_model,
account=account,
node_id="node-99",
form_inputs={"answer": "42"},
inputs={"#node-1.result#": "LLM output"},
action="approve",
)
@dataclass
class DeliveryTestCase:
resource_cls: type
path: str
mode: AppMode
@pytest.mark.parametrize(
"case",
[
DeliveryTestCase(
resource_cls=workflow_module.WorkflowDraftHumanInputDeliveryTestApi,
path="/console/api/apps/app-123/workflows/draft/human-input/nodes/node-7/delivery-test",
mode=AppMode.ADVANCED_CHAT,
),
DeliveryTestCase(
resource_cls=workflow_module.WorkflowDraftHumanInputDeliveryTestApi,
path="/console/api/apps/app-123/workflows/draft/human-input/nodes/node-7/delivery-test",
mode=AppMode.WORKFLOW,
),
],
)
def test_human_input_delivery_test_calls_service(
app: Flask, monkeypatch: pytest.MonkeyPatch, case: DeliveryTestCase
) -> None:
account = _make_account()
app_model = _make_app(case.mode)
_patch_console_guards(monkeypatch, account, app_model)
service_instance = MagicMock()
monkeypatch.setattr(workflow_module, "WorkflowService", MagicMock(return_value=service_instance))
with app.test_request_context(
case.path,
method="POST",
json={"delivery_method_id": "delivery-123"},
):
response = case.resource_cls().post(app_id=app_model.id, node_id="node-7")
assert response == {}
service_instance.test_human_input_delivery.assert_called_once_with(
app_model=app_model,
account=account,
node_id="node-7",
delivery_method_id="delivery-123",
inputs={},
)
def test_human_input_delivery_test_maps_validation_error(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
account = _make_account()
app_model = _make_app(AppMode.ADVANCED_CHAT)
_patch_console_guards(monkeypatch, account, app_model)
service_instance = MagicMock()
service_instance.test_human_input_delivery.side_effect = ValueError("bad delivery method")
monkeypatch.setattr(workflow_module, "WorkflowService", MagicMock(return_value=service_instance))
with app.test_request_context(
"/console/api/apps/app-123/workflows/draft/human-input/nodes/node-1/delivery-test",
method="POST",
json={"delivery_method_id": "bad"},
):
with pytest.raises(ValueError):
workflow_module.WorkflowDraftHumanInputDeliveryTestApi().post(app_id=app_model.id, node_id="node-1")
def test_human_input_preview_rejects_non_mapping(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
account = _make_account()
app_model = _make_app(AppMode.ADVANCED_CHAT)
_patch_console_guards(monkeypatch, account, app_model)
with app.test_request_context(
"/console/api/apps/app-123/advanced-chat/workflows/draft/human-input/nodes/node-1/form/preview",
method="POST",
json={"inputs": ["not-a-dict"]},
):
with pytest.raises(ValidationError):
workflow_module.AdvancedChatDraftHumanInputFormPreviewApi().post(app_id=app_model.id, node_id="node-1")

View File

@ -0,0 +1,110 @@
from __future__ import annotations
from datetime import datetime
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from flask import Flask
from controllers.console import wraps as console_wraps
from controllers.console.app import workflow_run as workflow_run_module
from controllers.web.error import NotFoundError
from core.workflow.entities.pause_reason import HumanInputRequired
from core.workflow.enums import WorkflowExecutionStatus
from core.workflow.nodes.human_input.entities import FormInput, UserAction
from core.workflow.nodes.human_input.enums import FormInputType
from libs import login as login_lib
from models.account import Account, AccountStatus, TenantAccountRole
from models.workflow import WorkflowRun
def _make_account() -> Account:
account = Account(name="tester", email="tester@example.com")
account.status = AccountStatus.ACTIVE
account.role = TenantAccountRole.OWNER
account.id = "account-123" # type: ignore[assignment]
account._current_tenant = SimpleNamespace(id="tenant-123") # type: ignore[attr-defined]
account._get_current_object = lambda: account # type: ignore[attr-defined]
return account
def _patch_console_guards(monkeypatch: pytest.MonkeyPatch, account: Account) -> None:
monkeypatch.setattr(login_lib.dify_config, "LOGIN_DISABLED", True)
monkeypatch.setattr(login_lib, "current_user", account)
monkeypatch.setattr(login_lib, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
monkeypatch.setattr(login_lib, "check_csrf_token", lambda *_, **__: None)
monkeypatch.setattr(console_wraps, "current_account_with_tenant", lambda: (account, account.current_tenant_id))
monkeypatch.setattr(workflow_run_module, "current_user", account)
monkeypatch.setattr(console_wraps.dify_config, "EDITION", "CLOUD")
class _PauseEntity:
def __init__(self, paused_at: datetime, reasons: list[HumanInputRequired]):
self.paused_at = paused_at
self._reasons = reasons
def get_pause_reasons(self):
return self._reasons
def test_pause_details_returns_backstage_input_url(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
account = _make_account()
_patch_console_guards(monkeypatch, account)
monkeypatch.setattr(workflow_run_module.dify_config, "APP_WEB_URL", "https://web.example.com")
workflow_run = Mock(spec=WorkflowRun)
workflow_run.tenant_id = "tenant-123"
workflow_run.status = WorkflowExecutionStatus.PAUSED
workflow_run.created_at = datetime(2024, 1, 1, 12, 0, 0)
fake_db = SimpleNamespace(engine=Mock(), session=SimpleNamespace(get=lambda *_: workflow_run))
monkeypatch.setattr(workflow_run_module, "db", fake_db)
reason = HumanInputRequired(
form_id="form-1",
form_content="content",
inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="name")],
actions=[UserAction(id="approve", title="Approve")],
node_id="node-1",
node_title="Ask Name",
form_token="backstage-token",
)
pause_entity = _PauseEntity(paused_at=datetime(2024, 1, 1, 12, 0, 0), reasons=[reason])
repo = Mock()
repo.get_workflow_pause.return_value = pause_entity
monkeypatch.setattr(
workflow_run_module.DifyAPIRepositoryFactory,
"create_api_workflow_run_repository",
lambda *_, **__: repo,
)
with app.test_request_context("/console/api/workflow/run-1/pause-details", method="GET"):
response, status = workflow_run_module.ConsoleWorkflowPauseDetailsApi().get(workflow_run_id="run-1")
assert status == 200
assert response["paused_at"] == "2024-01-01T12:00:00Z"
assert response["paused_nodes"][0]["node_id"] == "node-1"
assert response["paused_nodes"][0]["pause_type"]["type"] == "human_input"
assert (
response["paused_nodes"][0]["pause_type"]["backstage_input_url"]
== "https://web.example.com/form/backstage-token"
)
assert "pending_human_inputs" not in response
def test_pause_details_tenant_isolation(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
account = _make_account()
_patch_console_guards(monkeypatch, account)
monkeypatch.setattr(workflow_run_module.dify_config, "APP_WEB_URL", "https://web.example.com")
workflow_run = Mock(spec=WorkflowRun)
workflow_run.tenant_id = "tenant-456"
workflow_run.status = WorkflowExecutionStatus.PAUSED
workflow_run.created_at = datetime(2024, 1, 1, 12, 0, 0)
fake_db = SimpleNamespace(engine=Mock(), session=SimpleNamespace(get=lambda *_: workflow_run))
monkeypatch.setattr(workflow_run_module, "db", fake_db)
with pytest.raises(NotFoundError):
with app.test_request_context("/console/api/workflow/run-1/pause-details", method="GET"):
response, status = workflow_run_module.ConsoleWorkflowPauseDetailsApi().get(workflow_run_id="run-1")