mirror of
https://github.com/langgenius/dify.git
synced 2026-03-20 14:07:59 +08:00
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:
@ -0,0 +1,226 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import queue
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from threading import Event
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.app_config.entities import WorkflowUIBasedAppConfig
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
|
||||
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper
|
||||
from core.workflow.entities.pause_reason import HumanInputRequired
|
||||
from core.workflow.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus
|
||||
from core.workflow.runtime import GraphRuntimeState, VariablePool
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import AppMode
|
||||
from models.workflow import WorkflowRun
|
||||
from repositories.api_workflow_node_execution_repository import WorkflowNodeExecutionSnapshot
|
||||
from repositories.entities.workflow_pause import WorkflowPauseEntity
|
||||
from services.workflow_event_snapshot_service import (
|
||||
BufferState,
|
||||
MessageContext,
|
||||
_build_snapshot_events,
|
||||
_resolve_task_id,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _FakePauseEntity(WorkflowPauseEntity):
|
||||
pause_id: str
|
||||
workflow_run_id: str
|
||||
paused_at_value: datetime
|
||||
pause_reasons: Sequence[HumanInputRequired]
|
||||
|
||||
@property
|
||||
def id(self) -> str:
|
||||
return self.pause_id
|
||||
|
||||
@property
|
||||
def workflow_execution_id(self) -> str:
|
||||
return self.workflow_run_id
|
||||
|
||||
def get_state(self) -> bytes:
|
||||
raise AssertionError("state is not required for snapshot tests")
|
||||
|
||||
@property
|
||||
def resumed_at(self) -> datetime | None:
|
||||
return None
|
||||
|
||||
@property
|
||||
def paused_at(self) -> datetime:
|
||||
return self.paused_at_value
|
||||
|
||||
def get_pause_reasons(self) -> Sequence[HumanInputRequired]:
|
||||
return self.pause_reasons
|
||||
|
||||
|
||||
def _build_workflow_run(status: WorkflowExecutionStatus) -> WorkflowRun:
|
||||
return WorkflowRun(
|
||||
id="run-1",
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
workflow_id="workflow-1",
|
||||
type="workflow",
|
||||
triggered_from="app-run",
|
||||
version="v1",
|
||||
graph=None,
|
||||
inputs=json.dumps({"input": "value"}),
|
||||
status=status,
|
||||
outputs=json.dumps({}),
|
||||
error=None,
|
||||
elapsed_time=0.0,
|
||||
total_tokens=0,
|
||||
total_steps=0,
|
||||
created_by_role=CreatorUserRole.END_USER,
|
||||
created_by="user-1",
|
||||
created_at=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
)
|
||||
|
||||
|
||||
def _build_snapshot(status: WorkflowNodeExecutionStatus) -> WorkflowNodeExecutionSnapshot:
|
||||
created_at = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
finished_at = datetime(2024, 1, 1, 0, 0, 5, tzinfo=UTC)
|
||||
return WorkflowNodeExecutionSnapshot(
|
||||
execution_id="exec-1",
|
||||
node_id="node-1",
|
||||
node_type="human-input",
|
||||
title="Human Input",
|
||||
index=1,
|
||||
status=status.value,
|
||||
elapsed_time=0.5,
|
||||
created_at=created_at,
|
||||
finished_at=finished_at,
|
||||
iteration_id=None,
|
||||
loop_id=None,
|
||||
)
|
||||
|
||||
|
||||
def _build_resumption_context(task_id: str) -> WorkflowResumptionContext:
|
||||
app_config = WorkflowUIBasedAppConfig(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
app_mode=AppMode.WORKFLOW,
|
||||
workflow_id="workflow-1",
|
||||
)
|
||||
generate_entity = WorkflowAppGenerateEntity(
|
||||
task_id=task_id,
|
||||
app_config=app_config,
|
||||
inputs={},
|
||||
files=[],
|
||||
user_id="user-1",
|
||||
stream=True,
|
||||
invoke_from=InvokeFrom.EXPLORE,
|
||||
call_depth=0,
|
||||
workflow_execution_id="run-1",
|
||||
)
|
||||
runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0)
|
||||
runtime_state.register_paused_node("node-1")
|
||||
runtime_state.outputs = {"result": "value"}
|
||||
wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity)
|
||||
return WorkflowResumptionContext(
|
||||
generate_entity=wrapper,
|
||||
serialized_graph_runtime_state=runtime_state.dumps(),
|
||||
)
|
||||
|
||||
|
||||
def test_build_snapshot_events_includes_pause_event() -> None:
|
||||
workflow_run = _build_workflow_run(WorkflowExecutionStatus.PAUSED)
|
||||
snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED)
|
||||
resumption_context = _build_resumption_context("task-ctx")
|
||||
pause_entity = _FakePauseEntity(
|
||||
pause_id="pause-1",
|
||||
workflow_run_id="run-1",
|
||||
paused_at_value=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
pause_reasons=[
|
||||
HumanInputRequired(
|
||||
form_id="form-1",
|
||||
form_content="content",
|
||||
node_id="node-1",
|
||||
node_title="Human Input",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
events = _build_snapshot_events(
|
||||
workflow_run=workflow_run,
|
||||
node_snapshots=[snapshot],
|
||||
task_id="task-ctx",
|
||||
message_context=None,
|
||||
pause_entity=pause_entity,
|
||||
resumption_context=resumption_context,
|
||||
)
|
||||
|
||||
assert [event["event"] for event in events] == [
|
||||
"workflow_started",
|
||||
"node_started",
|
||||
"node_finished",
|
||||
"workflow_paused",
|
||||
]
|
||||
assert events[2]["data"]["status"] == WorkflowNodeExecutionStatus.PAUSED.value
|
||||
pause_data = events[-1]["data"]
|
||||
assert pause_data["paused_nodes"] == ["node-1"]
|
||||
assert pause_data["outputs"] == {"result": "value"}
|
||||
assert pause_data["status"] == WorkflowExecutionStatus.PAUSED.value
|
||||
assert pause_data["created_at"] == int(workflow_run.created_at.timestamp())
|
||||
assert pause_data["elapsed_time"] == workflow_run.elapsed_time
|
||||
assert pause_data["total_tokens"] == workflow_run.total_tokens
|
||||
assert pause_data["total_steps"] == workflow_run.total_steps
|
||||
|
||||
|
||||
def test_build_snapshot_events_applies_message_context() -> None:
|
||||
workflow_run = _build_workflow_run(WorkflowExecutionStatus.RUNNING)
|
||||
snapshot = _build_snapshot(WorkflowNodeExecutionStatus.SUCCEEDED)
|
||||
message_context = MessageContext(
|
||||
conversation_id="conv-1",
|
||||
message_id="msg-1",
|
||||
created_at=1700000000,
|
||||
answer="snapshot message",
|
||||
)
|
||||
|
||||
events = _build_snapshot_events(
|
||||
workflow_run=workflow_run,
|
||||
node_snapshots=[snapshot],
|
||||
task_id="task-1",
|
||||
message_context=message_context,
|
||||
pause_entity=None,
|
||||
resumption_context=None,
|
||||
)
|
||||
|
||||
assert [event["event"] for event in events] == [
|
||||
"workflow_started",
|
||||
"message_replace",
|
||||
"node_started",
|
||||
"node_finished",
|
||||
]
|
||||
assert events[1]["answer"] == "snapshot message"
|
||||
for event in events:
|
||||
assert event["conversation_id"] == "conv-1"
|
||||
assert event["message_id"] == "msg-1"
|
||||
assert event["created_at"] == 1700000000
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("context_task_id", "buffered_task_id", "expected"),
|
||||
[
|
||||
("task-ctx", "task-buffer", "task-ctx"),
|
||||
(None, "task-buffer", "task-buffer"),
|
||||
(None, None, "run-1"),
|
||||
],
|
||||
)
|
||||
def test_resolve_task_id_priority(context_task_id, buffered_task_id, expected) -> None:
|
||||
resumption_context = _build_resumption_context(context_task_id) if context_task_id else None
|
||||
buffer_state = BufferState(
|
||||
queue=queue.Queue(),
|
||||
stop_event=Event(),
|
||||
done_event=Event(),
|
||||
task_id_ready=Event(),
|
||||
task_id_hint=buffered_task_id,
|
||||
)
|
||||
if buffered_task_id:
|
||||
buffer_state.task_id_ready.set()
|
||||
task_id = _resolve_task_id(resumption_context, buffer_state, "run-1", wait_timeout=0.0)
|
||||
assert task_id == expected
|
||||
@ -0,0 +1,184 @@
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
from core.workflow.enums import NodeType
|
||||
from core.workflow.nodes.human_input.entities import (
|
||||
EmailDeliveryConfig,
|
||||
EmailDeliveryMethod,
|
||||
EmailRecipients,
|
||||
ExternalRecipient,
|
||||
HumanInputNodeData,
|
||||
MemberRecipient,
|
||||
)
|
||||
from services import workflow_service as workflow_service_module
|
||||
from services.workflow_service import WorkflowService
|
||||
|
||||
|
||||
def _make_service() -> WorkflowService:
|
||||
return WorkflowService(session_maker=sessionmaker())
|
||||
|
||||
|
||||
def _build_node_config(delivery_methods):
|
||||
node_data = HumanInputNodeData(
|
||||
title="Human Input",
|
||||
delivery_methods=delivery_methods,
|
||||
form_content="Test content",
|
||||
inputs=[],
|
||||
user_actions=[],
|
||||
).model_dump(mode="json")
|
||||
node_data["type"] = NodeType.HUMAN_INPUT.value
|
||||
return {"id": "node-1", "data": node_data}
|
||||
|
||||
|
||||
def _make_email_method(enabled: bool = True, debug_mode: bool = False) -> EmailDeliveryMethod:
|
||||
return EmailDeliveryMethod(
|
||||
id=uuid.uuid4(),
|
||||
enabled=enabled,
|
||||
config=EmailDeliveryConfig(
|
||||
recipients=EmailRecipients(
|
||||
whole_workspace=False,
|
||||
items=[ExternalRecipient(email="tester@example.com")],
|
||||
),
|
||||
subject="Test subject",
|
||||
body="Test body",
|
||||
debug_mode=debug_mode,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def test_human_input_delivery_requires_draft_workflow():
|
||||
service = _make_service()
|
||||
service.get_draft_workflow = MagicMock(return_value=None) # type: ignore[method-assign]
|
||||
app_model = SimpleNamespace(tenant_id="tenant-1", id="app-1")
|
||||
account = SimpleNamespace(id="account-1")
|
||||
|
||||
with pytest.raises(ValueError, match="Workflow not initialized"):
|
||||
service.test_human_input_delivery(
|
||||
app_model=app_model,
|
||||
account=account,
|
||||
node_id="node-1",
|
||||
delivery_method_id="delivery-1",
|
||||
)
|
||||
|
||||
|
||||
def test_human_input_delivery_allows_disabled_method(monkeypatch: pytest.MonkeyPatch):
|
||||
service = _make_service()
|
||||
delivery_method = _make_email_method(enabled=False)
|
||||
node_config = _build_node_config([delivery_method])
|
||||
workflow = MagicMock()
|
||||
workflow.get_node_config_by_id.return_value = node_config
|
||||
service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign]
|
||||
service._build_human_input_variable_pool = MagicMock(return_value=MagicMock()) # type: ignore[attr-defined]
|
||||
node_stub = MagicMock()
|
||||
node_stub._render_form_content_before_submission.return_value = "rendered"
|
||||
node_stub._resolve_default_values.return_value = {}
|
||||
service._build_human_input_node = MagicMock(return_value=node_stub) # type: ignore[attr-defined]
|
||||
service._create_human_input_delivery_test_form = MagicMock( # type: ignore[attr-defined]
|
||||
return_value=("form-1", {})
|
||||
)
|
||||
|
||||
test_service_instance = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
workflow_service_module,
|
||||
"HumanInputDeliveryTestService",
|
||||
MagicMock(return_value=test_service_instance),
|
||||
)
|
||||
|
||||
app_model = SimpleNamespace(tenant_id="tenant-1", id="app-1")
|
||||
account = SimpleNamespace(id="account-1")
|
||||
|
||||
service.test_human_input_delivery(
|
||||
app_model=app_model,
|
||||
account=account,
|
||||
node_id="node-1",
|
||||
delivery_method_id=str(delivery_method.id),
|
||||
)
|
||||
|
||||
test_service_instance.send_test.assert_called_once()
|
||||
|
||||
|
||||
def test_human_input_delivery_dispatches_to_test_service(monkeypatch: pytest.MonkeyPatch):
|
||||
service = _make_service()
|
||||
delivery_method = _make_email_method(enabled=True)
|
||||
node_config = _build_node_config([delivery_method])
|
||||
workflow = MagicMock()
|
||||
workflow.get_node_config_by_id.return_value = node_config
|
||||
service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign]
|
||||
service._build_human_input_variable_pool = MagicMock(return_value=MagicMock()) # type: ignore[attr-defined]
|
||||
node_stub = MagicMock()
|
||||
node_stub._render_form_content_before_submission.return_value = "rendered"
|
||||
node_stub._resolve_default_values.return_value = {}
|
||||
service._build_human_input_node = MagicMock(return_value=node_stub) # type: ignore[attr-defined]
|
||||
service._create_human_input_delivery_test_form = MagicMock( # type: ignore[attr-defined]
|
||||
return_value=("form-1", {})
|
||||
)
|
||||
|
||||
test_service_instance = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
workflow_service_module,
|
||||
"HumanInputDeliveryTestService",
|
||||
MagicMock(return_value=test_service_instance),
|
||||
)
|
||||
|
||||
app_model = SimpleNamespace(tenant_id="tenant-1", id="app-1")
|
||||
account = SimpleNamespace(id="account-1")
|
||||
|
||||
service.test_human_input_delivery(
|
||||
app_model=app_model,
|
||||
account=account,
|
||||
node_id="node-1",
|
||||
delivery_method_id=str(delivery_method.id),
|
||||
inputs={"#node-1.output#": "value"},
|
||||
)
|
||||
|
||||
pool_args = service._build_human_input_variable_pool.call_args.kwargs
|
||||
assert pool_args["manual_inputs"] == {"#node-1.output#": "value"}
|
||||
test_service_instance.send_test.assert_called_once()
|
||||
|
||||
|
||||
def test_human_input_delivery_debug_mode_overrides_recipients(monkeypatch: pytest.MonkeyPatch):
|
||||
service = _make_service()
|
||||
delivery_method = _make_email_method(enabled=True, debug_mode=True)
|
||||
node_config = _build_node_config([delivery_method])
|
||||
workflow = MagicMock()
|
||||
workflow.get_node_config_by_id.return_value = node_config
|
||||
service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign]
|
||||
service._build_human_input_variable_pool = MagicMock(return_value=MagicMock()) # type: ignore[attr-defined]
|
||||
node_stub = MagicMock()
|
||||
node_stub._render_form_content_before_submission.return_value = "rendered"
|
||||
node_stub._resolve_default_values.return_value = {}
|
||||
service._build_human_input_node = MagicMock(return_value=node_stub) # type: ignore[attr-defined]
|
||||
service._create_human_input_delivery_test_form = MagicMock( # type: ignore[attr-defined]
|
||||
return_value=("form-1", {})
|
||||
)
|
||||
|
||||
test_service_instance = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
workflow_service_module,
|
||||
"HumanInputDeliveryTestService",
|
||||
MagicMock(return_value=test_service_instance),
|
||||
)
|
||||
|
||||
app_model = SimpleNamespace(tenant_id="tenant-1", id="app-1")
|
||||
account = SimpleNamespace(id="account-1")
|
||||
|
||||
service.test_human_input_delivery(
|
||||
app_model=app_model,
|
||||
account=account,
|
||||
node_id="node-1",
|
||||
delivery_method_id=str(delivery_method.id),
|
||||
)
|
||||
|
||||
test_service_instance.send_test.assert_called_once()
|
||||
sent_method = test_service_instance.send_test.call_args.kwargs["method"]
|
||||
assert isinstance(sent_method, EmailDeliveryMethod)
|
||||
assert sent_method.config.debug_mode is True
|
||||
assert sent_method.config.recipients.whole_workspace is False
|
||||
assert len(sent_method.config.recipients.items) == 1
|
||||
recipient = sent_method.config.recipients.items[0]
|
||||
assert isinstance(recipient, MemberRecipient)
|
||||
assert recipient.user_id == account.id
|
||||
@ -5,6 +5,7 @@ from uuid import uuid4
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.workflow.enums import WorkflowNodeExecutionStatus
|
||||
from models.workflow import WorkflowNodeExecutionModel
|
||||
from repositories.sqlalchemy_api_workflow_node_execution_repository import (
|
||||
DifyAPISQLAlchemyWorkflowNodeExecutionRepository,
|
||||
@ -52,6 +53,9 @@ class TestSQLAlchemyWorkflowNodeExecutionServiceRepository:
|
||||
call_args = mock_session.scalar.call_args[0][0]
|
||||
assert hasattr(call_args, "compile") # It's a SQLAlchemy statement
|
||||
|
||||
compiled = call_args.compile()
|
||||
assert WorkflowNodeExecutionStatus.PAUSED in compiled.params.values()
|
||||
|
||||
def test_get_node_last_execution_not_found(self, repository):
|
||||
"""Test getting the last execution for a node when it doesn't exist."""
|
||||
# Arrange
|
||||
@ -71,28 +75,6 @@ class TestSQLAlchemyWorkflowNodeExecutionServiceRepository:
|
||||
assert result is None
|
||||
mock_session.scalar.assert_called_once()
|
||||
|
||||
def test_get_executions_by_workflow_run(self, repository, mock_execution):
|
||||
"""Test getting all executions for a workflow run."""
|
||||
# Arrange
|
||||
mock_session = MagicMock(spec=Session)
|
||||
repository._session_maker.return_value.__enter__.return_value = mock_session
|
||||
executions = [mock_execution]
|
||||
mock_session.execute.return_value.scalars.return_value.all.return_value = executions
|
||||
|
||||
# Act
|
||||
result = repository.get_executions_by_workflow_run(
|
||||
tenant_id="tenant-123",
|
||||
app_id="app-456",
|
||||
workflow_run_id="run-101",
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result == executions
|
||||
mock_session.execute.assert_called_once()
|
||||
# Verify the query was constructed correctly
|
||||
call_args = mock_session.execute.call_args[0][0]
|
||||
assert hasattr(call_args, "compile") # It's a SQLAlchemy statement
|
||||
|
||||
def test_get_executions_by_workflow_run_empty(self, repository):
|
||||
"""Test getting executions for a workflow run when none exist."""
|
||||
# Arrange
|
||||
|
||||
@ -1,9 +1,15 @@
|
||||
from contextlib import nullcontext
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.workflow.enums import NodeType
|
||||
from core.workflow.nodes.human_input.entities import FormInput, HumanInputNodeData, UserAction
|
||||
from core.workflow.nodes.human_input.enums import FormInputType
|
||||
from models.model import App
|
||||
from models.workflow import Workflow
|
||||
from services import workflow_service as workflow_service_module
|
||||
from services.workflow_service import WorkflowService
|
||||
|
||||
|
||||
@ -161,3 +167,120 @@ class TestWorkflowService:
|
||||
assert workflows == []
|
||||
assert has_more is False
|
||||
mock_session.scalars.assert_called_once()
|
||||
|
||||
def test_submit_human_input_form_preview_uses_rendered_content(
|
||||
self, workflow_service: WorkflowService, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
service = workflow_service
|
||||
node_data = HumanInputNodeData(
|
||||
title="Human Input",
|
||||
form_content="<p>{{#$output.name#}}</p>",
|
||||
inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="name")],
|
||||
user_actions=[UserAction(id="approve", title="Approve")],
|
||||
)
|
||||
node = MagicMock()
|
||||
node.node_data = node_data
|
||||
node.render_form_content_before_submission.return_value = "<p>preview</p>"
|
||||
node.render_form_content_with_outputs.return_value = "<p>rendered</p>"
|
||||
|
||||
service._build_human_input_variable_pool = MagicMock(return_value=MagicMock()) # type: ignore[method-assign]
|
||||
service._build_human_input_node = MagicMock(return_value=node) # type: ignore[method-assign]
|
||||
|
||||
workflow = MagicMock()
|
||||
workflow.get_node_config_by_id.return_value = {"id": "node-1", "data": {"type": NodeType.HUMAN_INPUT.value}}
|
||||
workflow.get_enclosing_node_type_and_id.return_value = None
|
||||
service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign]
|
||||
|
||||
saved_outputs: dict[str, object] = {}
|
||||
|
||||
class DummySession:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.commit = MagicMock()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def begin(self):
|
||||
return nullcontext()
|
||||
|
||||
class DummySaver:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def save(self, outputs, process_data):
|
||||
saved_outputs.update(outputs)
|
||||
|
||||
monkeypatch.setattr(workflow_service_module, "Session", DummySession)
|
||||
monkeypatch.setattr(workflow_service_module, "DraftVariableSaver", DummySaver)
|
||||
monkeypatch.setattr(workflow_service_module, "db", SimpleNamespace(engine=MagicMock()))
|
||||
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
|
||||
account = SimpleNamespace(id="account-1")
|
||||
|
||||
result = service.submit_human_input_form_preview(
|
||||
app_model=app_model,
|
||||
account=account,
|
||||
node_id="node-1",
|
||||
form_inputs={"name": "Ada", "extra": "ignored"},
|
||||
inputs={"#node-0.result#": "LLM output"},
|
||||
action="approve",
|
||||
)
|
||||
|
||||
service._build_human_input_variable_pool.assert_called_once_with(
|
||||
app_model=app_model,
|
||||
workflow=workflow,
|
||||
node_config={"id": "node-1", "data": {"type": NodeType.HUMAN_INPUT.value}},
|
||||
manual_inputs={"#node-0.result#": "LLM output"},
|
||||
)
|
||||
|
||||
node.render_form_content_with_outputs.assert_called_once()
|
||||
called_args = node.render_form_content_with_outputs.call_args.args
|
||||
assert called_args[0] == "<p>preview</p>"
|
||||
assert called_args[2] == node_data.outputs_field_names()
|
||||
rendered_outputs = called_args[1]
|
||||
assert rendered_outputs["name"] == "Ada"
|
||||
assert rendered_outputs["extra"] == "ignored"
|
||||
assert "extra" in saved_outputs
|
||||
assert "extra" in result
|
||||
assert saved_outputs["name"] == "Ada"
|
||||
assert result["name"] == "Ada"
|
||||
assert result["__action_id"] == "approve"
|
||||
assert "__rendered_content" in result
|
||||
|
||||
def test_submit_human_input_form_preview_missing_inputs_message(self, workflow_service: WorkflowService) -> None:
|
||||
service = workflow_service
|
||||
node_data = HumanInputNodeData(
|
||||
title="Human Input",
|
||||
form_content="<p>{{#$output.name#}}</p>",
|
||||
inputs=[FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="name")],
|
||||
user_actions=[UserAction(id="approve", title="Approve")],
|
||||
)
|
||||
node = MagicMock()
|
||||
node.node_data = node_data
|
||||
node._render_form_content_before_submission.return_value = "<p>preview</p>"
|
||||
node._render_form_content_with_outputs.return_value = "<p>rendered</p>"
|
||||
|
||||
service._build_human_input_variable_pool = MagicMock(return_value=MagicMock()) # type: ignore[method-assign]
|
||||
service._build_human_input_node = MagicMock(return_value=node) # type: ignore[method-assign]
|
||||
|
||||
workflow = MagicMock()
|
||||
workflow.get_node_config_by_id.return_value = {"id": "node-1", "data": {"type": NodeType.HUMAN_INPUT.value}}
|
||||
service.get_draft_workflow = MagicMock(return_value=workflow) # type: ignore[method-assign]
|
||||
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
|
||||
account = SimpleNamespace(id="account-1")
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
service.submit_human_input_form_preview(
|
||||
app_model=app_model,
|
||||
account=account,
|
||||
node_id="node-1",
|
||||
form_inputs={},
|
||||
inputs={},
|
||||
action="approve",
|
||||
)
|
||||
|
||||
assert "Missing required inputs" in str(exc_info.value)
|
||||
|
||||
Reference in New Issue
Block a user