feat(api): introduce select, file and file list form input types to Human Input node (#36322)

Co-authored-by: JzoNg <jzongcode@gmail.com>
Co-authored-by: GPT 5.4 <codex@openai.com>
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by: -LAN- <laipz8200@outlook.com>
This commit is contained in:
QuantumGhost
2026-06-04 01:54:28 +00:00
committed by GitHub
co-authored by JzoNg GPT 5.4 autofix-ci[bot] -LAN-
parent 44725dde74
commit 3c98f96ae8
188 changed files with 11092 additions and 1156 deletions
@@ -0,0 +1,239 @@
from __future__ import annotations
import json
from datetime import UTC, datetime, timedelta
from typing import override
from uuid import uuid4
import pytest
from flask.testing import FlaskClient
from sqlalchemy import Engine
from sqlalchemy.orm import Session, sessionmaker
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.human_input_adapter import DeliveryMethodType
from graphon.entities import WorkflowExecution
from graphon.entities.pause_reason import HumanInputRequired
from graphon.enums import WorkflowExecutionStatus
from graphon.nodes.human_input.entities import FormDefinition, SelectInputConfig, StringListSource, UserActionConfig
from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType
from graphon.runtime import GraphRuntimeState, VariablePool
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
from models.human_input import (
HumanInputDelivery,
HumanInputForm,
HumanInputFormRecipient,
RecipientType,
StandaloneWebAppRecipientPayload,
)
from models.model import App, AppMode, CustomizeTokenStrategy, Site
from models.workflow import WorkflowRun, WorkflowType
from repositories.sqlalchemy_api_workflow_run_repository import DifyAPISQLAlchemyWorkflowRunRepository
from services.feature_service import FeatureModel
class _TestWorkflowRunRepository(DifyAPISQLAlchemyWorkflowRunRepository):
"""Concrete repository for tests where save() is not under test."""
@override
def save(self, execution: WorkflowExecution) -> None:
return None
def _create_app_with_site(session: Session) -> tuple[App, Account]:
tenant = Tenant(name="Test Tenant")
account = Account(name="Tester", email=f"tester-{uuid4()}@example.com")
session.add_all([tenant, account])
session.flush()
session.add(
TenantAccountJoin(
tenant_id=tenant.id,
account_id=account.id,
current=True,
role=TenantAccountRole.OWNER,
)
)
app = App(
tenant_id=tenant.id,
name="Test App",
description="",
mode=AppMode.WORKFLOW.value,
icon_type="emoji",
icon="app",
icon_background="#ffffff",
enable_site=True,
enable_api=True,
created_by=account.id,
updated_by=account.id,
)
session.add(app)
session.flush()
site = Site(
app_id=app.id,
title="Test Site",
icon_type="emoji",
icon="robot",
icon_background="#ffffff",
description="desc",
default_language="en",
chat_color_theme="light",
chat_color_theme_inverted=False,
customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW,
code=f"code-{uuid4().hex[:8]}",
prompt_public=False,
show_workflow_steps=True,
use_icon_as_answer_icon=False,
)
session.add(site)
session.flush()
return app, account
def _build_resumption_context(*, app: App, workflow_run: WorkflowRun, options: list[str]) -> WorkflowResumptionContext:
app_config = WorkflowUIBasedAppConfig(
tenant_id=app.tenant_id,
app_id=app.id,
app_mode=AppMode.WORKFLOW,
workflow_id=workflow_run.workflow_id,
)
generate_entity = WorkflowAppGenerateEntity(
task_id="task-1",
app_config=app_config,
inputs={},
files=[],
user_id=str(uuid4()),
stream=True,
invoke_from=InvokeFrom.WEB_APP,
call_depth=0,
workflow_execution_id=workflow_run.id,
)
runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0)
runtime_state.variable_pool.add(("start", "options"), options)
return WorkflowResumptionContext(
generate_entity=_WorkflowGenerateEntityWrapper(entity=generate_entity),
serialized_graph_runtime_state=runtime_state.dumps(),
)
def test_get_human_input_form_resolves_runtime_select_options(
db_session_with_containers: Session,
test_client_with_containers: FlaskClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
app, account = _create_app_with_site(db_session_with_containers)
workflow_run = WorkflowRun(
tenant_id=app.tenant_id,
app_id=app.id,
workflow_id=str(uuid4()),
type=WorkflowType.WORKFLOW,
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
version="v1",
graph=None,
inputs="{}",
status=WorkflowExecutionStatus.RUNNING,
outputs="{}",
error=None,
elapsed_time=0.0,
total_tokens=0,
total_steps=0,
created_by_role=CreatorUserRole.ACCOUNT,
created_by=account.id,
created_at=datetime.now(UTC).replace(tzinfo=None),
)
db_session_with_containers.add(workflow_run)
db_session_with_containers.flush()
configured_input = SelectInputConfig(
output_variable_name="decision",
option_source=StringListSource(
type=ValueSourceType.VARIABLE,
selector=["start", "options"],
value=["configured"],
),
)
expiration_time = datetime.now(UTC).replace(tzinfo=None) + timedelta(hours=1)
form_definition = FormDefinition(
form_content="Choose",
rendered_content="Choose",
inputs=[configured_input],
user_actions=[UserActionConfig(id="approve", title="Approve")],
expiration_time=expiration_time,
)
form = HumanInputForm(
tenant_id=app.tenant_id,
app_id=app.id,
workflow_run_id=workflow_run.id,
form_kind=HumanInputFormKind.RUNTIME,
node_id="human-node",
form_definition=form_definition.model_dump_json(),
rendered_content="Choose",
status=HumanInputFormStatus.WAITING,
expiration_time=expiration_time,
)
db_session_with_containers.add(form)
db_session_with_containers.flush()
delivery = HumanInputDelivery(
form_id=form.id,
delivery_method_type=DeliveryMethodType.WEBAPP,
channel_payload="{}",
)
db_session_with_containers.add(delivery)
db_session_with_containers.flush()
access_token = f"hitl{uuid4().hex[:18]}"
recipient = HumanInputFormRecipient(
form_id=form.id,
delivery_id=delivery.id,
recipient_type=RecipientType.STANDALONE_WEB_APP,
recipient_payload=StandaloneWebAppRecipientPayload().model_dump_json(),
access_token=access_token,
)
db_session_with_containers.add(recipient)
db_session_with_containers.commit()
context = _build_resumption_context(
app=app,
workflow_run=workflow_run,
options=["approve", "reject"],
)
reason = HumanInputRequired(
form_id=form.id,
form_content="Choose",
inputs=[configured_input],
actions=[UserActionConfig(id="approve", title="Approve")],
node_id="human-node",
node_title="Human Input",
)
engine = db_session_with_containers.get_bind()
assert isinstance(engine, Engine)
workflow_run_repo = _TestWorkflowRunRepository(session_maker=sessionmaker(bind=engine, expire_on_commit=False))
workflow_run_repo.create_workflow_pause(
workflow_run_id=workflow_run.id,
state_owner_user_id=account.id,
state=context.dumps(),
pause_reasons=[reason],
)
def mock_get_features(tenant_id: str, exclude_vector_space: bool = False) -> FeatureModel:
features = FeatureModel(can_replace_logo=True)
return features
monkeypatch.setattr(
"controllers.web.site.FeatureService.get_features",
mock_get_features,
)
response = test_client_with_containers.get(f"/api/form/human_input/{access_token}")
assert response.status_code == 200, response.get_data(as_text=True)
body = json.loads(response.get_data(as_text=True))
assert body["inputs"][0]["option_source"]["type"] == "variable"
assert body["inputs"][0]["option_source"]["selector"] == ["start", "options"]
assert body["inputs"][0]["option_source"]["value"] == ["approve", "reject"]
@@ -132,6 +132,7 @@ def create_human_input_message_fixture(db_session) -> HumanInputMessageFixture:
status=HumanInputFormStatus.SUBMITTED,
expiration_time=naive_utc_now() + timedelta(days=1),
selected_action_id=action_id,
submitted_data='{"name": "Alice"}',
)
db_session.add(form)
db_session.flush()
@@ -642,7 +642,7 @@ class TestBuildHumanInputRequiredReason:
expiration_time = naive_utc_now()
form_definition = FormDefinition(
form_content="content",
inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="name")],
inputs=[ParagraphInputConfig(output_variable_name="name")],
user_actions=[UserActionConfig(id="approve", title="Approve")],
rendered_content="rendered",
expiration_time=expiration_time,
@@ -5,6 +5,7 @@ Part of #32454 — replaces the mock-based unit tests with real database interac
from __future__ import annotations
import json
from collections.abc import Generator
from dataclasses import dataclass
from datetime import timedelta
@@ -174,11 +175,15 @@ def _create_submitted_form(
action_id: str = "approve",
action_title: str = "Approve",
node_title: str = "Approval",
form_content: str = "content",
rendered_content: str | None = None,
inputs: list[dict] | None = None,
submitted_data: dict | None = None,
) -> HumanInputForm:
expiration_time = naive_utc_now() + timedelta(days=1)
form_definition = FormDefinition(
form_content="content",
inputs=[],
form_content=form_content,
inputs=inputs or [],
user_actions=[UserActionConfig(id=action_id, title=action_title)],
rendered_content="rendered",
expiration_time=expiration_time,
@@ -191,10 +196,12 @@ def _create_submitted_form(
workflow_run_id=workflow_run_id,
node_id="node-id",
form_definition=form_definition.model_dump_json(),
rendered_content=f"Rendered {action_title}",
rendered_content=rendered_content or f"Rendered {action_title}",
status=HumanInputFormStatus.SUBMITTED,
expiration_time=expiration_time,
selected_action_id=action_id,
submitted_data=None if submitted_data is None else json.dumps(submitted_data),
submitted_at=naive_utc_now(),
)
session.add(form)
session.flush()
@@ -349,6 +356,127 @@ class TestGetByMessageIds:
# msg2 has no content
assert result[1] == []
def test_submitted_content_populates_submission_data_from_stored_form_data(
self,
db_session_with_containers: Session,
repository: SQLAlchemyExecutionExtraContentRepository,
test_scope: _TestScope,
) -> None:
workflow_run_id = str(uuid4())
conversation = _create_conversation(db_session_with_containers, test_scope)
msg = _create_message(db_session_with_containers, test_scope, conversation.id, workflow_run_id)
stored_submission_data = {"decision": "approve", "comment": "Looks good"}
form = _create_submitted_form(
db_session_with_containers,
test_scope,
workflow_run_id=workflow_run_id,
submitted_data=stored_submission_data,
)
_create_human_input_content(
db_session_with_containers,
workflow_run_id=workflow_run_id,
message_id=msg.id,
form_id=form.id,
)
db_session_with_containers.commit()
result = repository.get_by_message_ids([msg.id])
content = result[0][0]
assert content.form_submission_data is not None
assert content.form_submission_data.submitted_data == stored_submission_data
def test_submitted_content_exposes_select_and_file_form_data(
self,
db_session_with_containers: Session,
repository: SQLAlchemyExecutionExtraContentRepository,
test_scope: _TestScope,
) -> None:
workflow_run_id = str(uuid4())
conversation = _create_conversation(db_session_with_containers, test_scope)
msg = _create_message(db_session_with_containers, test_scope, conversation.id, workflow_run_id)
submitted_data = {
"decision": "approve",
"attachment": {
"type": "document",
"transfer_method": "remote_url",
"remote_url": "https://example.com/file.txt",
"filename": "file.txt",
"extension": ".txt",
"mime_type": "text/plain",
},
"attachments": [
{
"type": "document",
"transfer_method": "remote_url",
"remote_url": "https://example.com/first.txt",
"filename": "first.txt",
"extension": ".txt",
"mime_type": "text/plain",
},
{
"type": "document",
"transfer_method": "remote_url",
"remote_url": "https://example.com/second.txt",
"filename": "second.txt",
"extension": ".txt",
"mime_type": "text/plain",
},
],
}
form = _create_submitted_form(
db_session_with_containers,
test_scope,
workflow_run_id=workflow_run_id,
form_content=(
"Decision: {{#$output.decision#}}\n"
"Attachment: {{#$output.attachment#}}\n"
"Attachments: {{#$output.attachments#}}"
),
rendered_content=(
"Decision: {{#$output.decision#}}\n"
"Attachment: {{#$output.attachment#}}\n"
"Attachments: {{#$output.attachments#}}"
),
inputs=[
{
"type": "select",
"output_variable_name": "decision",
"option_source": {"type": "constant", "value": ["approve", "reject"]},
},
{
"type": "file",
"output_variable_name": "attachment",
"allowed_file_types": ["document"],
"allowed_file_upload_methods": ["remote_url"],
},
{
"type": "file-list",
"output_variable_name": "attachments",
"allowed_file_types": ["document"],
"allowed_file_upload_methods": ["remote_url"],
"number_limits": 3,
},
],
submitted_data=submitted_data,
)
_create_human_input_content(
db_session_with_containers,
workflow_run_id=workflow_run_id,
message_id=msg.id,
form_id=form.id,
)
db_session_with_containers.commit()
result = repository.get_by_message_ids([msg.id])
content = result[0][0]
assert content.form_submission_data is not None
assert content.form_submission_data.submitted_data == submitted_data
assert content.form_submission_data.rendered_content == (
"Decision: approve\nAttachment: [file]\nAttachments: [2 files]"
)
def test_returns_unsubmitted_form_definition(
self,
db_session_with_containers: Session,
@@ -1,9 +1,15 @@
import json
import uuid
from io import BytesIO
from unittest.mock import MagicMock
import httpx
import pytest
from flask.testing import FlaskClient
from sqlalchemy import select
from sqlalchemy.orm import Session
import controllers.web.human_input_file_upload as human_input_file_upload_module
from core.workflow.human_input_adapter import (
EmailDeliveryConfig,
EmailDeliveryMethod,
@@ -11,14 +17,21 @@ from core.workflow.human_input_adapter import (
ExternalRecipient,
)
from graphon.enums import BuiltinNodeTypes
from graphon.nodes.human_input.entities import HumanInputNodeData
from graphon.nodes.human_input.entities import FileInputConfig, HumanInputNodeData
from graphon.nodes.human_input.enums import HumanInputFormKind
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
from models.model import App, AppMode
from models.human_input import HumanInputForm, HumanInputFormRecipient, HumanInputFormUploadFile
from models.model import App, AppMode, UploadFile
from models.workflow import Workflow, WorkflowType
from services.workflow_service import WorkflowService
def _create_app_with_draft_workflow(session, *, delivery_method_id: uuid.UUID) -> tuple[App, Account]:
def _create_app_with_draft_workflow(
session: Session,
*,
delivery_method_id: uuid.UUID,
include_file_input: bool = False,
) -> tuple[App, Account]:
tenant = Tenant(name="Test Tenant")
account = Account(name="Tester", email="tester@example.com")
session.add_all([tenant, account])
@@ -65,7 +78,7 @@ def _create_app_with_draft_workflow(session, *, delivery_method_id: uuid.UUID) -
title="Human Input",
delivery_methods=[email_method],
form_content="Hello Human Input",
inputs=[],
inputs=[FileInputConfig(output_variable_name="attachment")] if include_file_input else [],
user_actions=[],
).model_dump(mode="json")
node_data["type"] = BuiltinNodeTypes.HUMAN_INPUT
@@ -110,3 +123,167 @@ def test_human_input_delivery_test_sends_email(
assert send_mock.call_count == 1
assert send_mock.call_args.kwargs["to"] == "recipient@example.com"
def test_human_input_delivery_test_form_accepts_file_upload(
db_session_with_containers: Session,
test_client_with_containers: FlaskClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
delivery_method_id = uuid.uuid4()
app, account = _create_app_with_draft_workflow(
db_session_with_containers,
delivery_method_id=delivery_method_id,
include_file_input=True,
)
monkeypatch.setattr("services.human_input_delivery_test_service.mail.is_inited", lambda: True)
monkeypatch.setattr("services.human_input_delivery_test_service.mail.send", MagicMock())
WorkflowService().test_human_input_delivery(
app_model=app,
account=account,
node_id="human-node",
delivery_method_id=str(delivery_method_id),
)
form = db_session_with_containers.scalar(
select(HumanInputForm)
.where(
HumanInputForm.app_id == app.id,
HumanInputForm.form_kind == HumanInputFormKind.DELIVERY_TEST,
HumanInputForm.workflow_run_id.is_(None),
)
.limit(1)
)
assert form is not None
recipient = db_session_with_containers.scalar(
select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id == form.id).limit(1)
)
assert recipient is not None
assert recipient.access_token is not None
token_response = test_client_with_containers.post(f"/api/form/human_input/{recipient.access_token}/upload-token")
assert token_response.status_code == 200
upload_token = token_response.get_json()["upload_token"]
upload_response = test_client_with_containers.post(
"/api/human-input-forms/files",
data={"file": (BytesIO(b"delivery test content"), "evidence.txt")},
content_type="multipart/form-data",
headers={"Authorization": f"Bearer {upload_token}"},
)
assert upload_response.status_code == 201, upload_response.get_data(as_text=True)
upload_file_id = upload_response.get_json()["id"]
db_session_with_containers.expire_all()
upload_file = db_session_with_containers.get(UploadFile, upload_file_id)
assert upload_file is not None
assert upload_file.tenant_id == app.tenant_id
assert upload_file.created_by == account.id
link = db_session_with_containers.scalar(
select(HumanInputFormUploadFile)
.where(
HumanInputFormUploadFile.form_id == form.id,
HumanInputFormUploadFile.upload_file_id == upload_file_id,
)
.limit(1)
)
assert link is not None
def test_human_input_delivery_test_form_accepts_remote_file_upload(
db_session_with_containers: Session,
test_client_with_containers: FlaskClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
delivery_method_id = uuid.uuid4()
app, account = _create_app_with_draft_workflow(
db_session_with_containers,
delivery_method_id=delivery_method_id,
include_file_input=True,
)
monkeypatch.setattr("services.human_input_delivery_test_service.mail.is_inited", lambda: True)
monkeypatch.setattr("services.human_input_delivery_test_service.mail.send", MagicMock())
WorkflowService().test_human_input_delivery(
app_model=app,
account=account,
node_id="human-node",
delivery_method_id=str(delivery_method_id),
)
form = db_session_with_containers.scalar(
select(HumanInputForm)
.where(
HumanInputForm.app_id == app.id,
HumanInputForm.form_kind == HumanInputFormKind.DELIVERY_TEST,
HumanInputForm.workflow_run_id.is_(None),
)
.limit(1)
)
assert form is not None
recipient = db_session_with_containers.scalar(
select(HumanInputFormRecipient).where(HumanInputFormRecipient.form_id == form.id).limit(1)
)
assert recipient is not None
assert recipient.access_token is not None
token_response = test_client_with_containers.post(f"/api/form/human_input/{recipient.access_token}/upload-token")
assert token_response.status_code == 200
upload_token = token_response.get_json()["upload_token"]
remote_url = "https://example.com/evidence.txt"
remote_content = b"delivery test remote content"
head_response = httpx.Response(
200,
request=httpx.Request("HEAD", remote_url),
headers={
"Content-Length": str(len(remote_content)),
"Content-Type": "text/plain",
},
)
get_response = httpx.Response(
200,
request=httpx.Request("GET", remote_url),
headers={
"Content-Length": str(len(remote_content)),
"Content-Type": "text/plain",
},
content=remote_content,
)
head_mock = MagicMock(return_value=head_response)
get_mock = MagicMock(return_value=get_response)
monkeypatch.setattr(human_input_file_upload_module.ssrf_proxy, "head", head_mock)
monkeypatch.setattr(human_input_file_upload_module.ssrf_proxy, "get", get_mock)
upload_response = test_client_with_containers.post(
"/api/human-input-forms/files",
data={"url": remote_url},
content_type="multipart/form-data",
headers={"Authorization": f"Bearer {upload_token}"},
)
assert upload_response.status_code == 201, upload_response.get_data(as_text=True)
upload_file_id = upload_response.get_json()["id"]
assert upload_response.get_json()["url"]
head_mock.assert_called_once_with(url=remote_url)
get_mock.assert_called_once_with(remote_url)
db_session_with_containers.expire_all()
upload_file = db_session_with_containers.get(UploadFile, upload_file_id)
assert upload_file is not None
assert upload_file.tenant_id == app.tenant_id
assert upload_file.created_by == account.id
assert upload_file.source_url == remote_url
link = db_session_with_containers.scalar(
select(HumanInputFormUploadFile)
.where(
HumanInputFormUploadFile.form_id == form.id,
HumanInputFormUploadFile.upload_file_id == upload_file_id,
)
.limit(1)
)
assert link is not None
@@ -0,0 +1,78 @@
import uuid
from datetime import datetime, timedelta
from unittest.mock import MagicMock
import pytest
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
import services.human_input_file_upload_service as service_module
from extensions.ext_database import db
from graphon.nodes.human_input.enums import HumanInputFormKind
from libs.datetime_utils import naive_utc_now
from models.human_input import (
HumanInputForm,
HumanInputFormRecipient,
HumanInputFormUploadToken,
StandaloneWebAppRecipientPayload,
)
from services.human_input_file_upload_service import HITL_UPLOAD_TOKEN_PREFIX, HumanInputFileUploadService
def _create_waiting_form_recipient(
db_session_with_containers: Session,
) -> tuple[str, str, datetime]:
form_id = "00000000-0000-0000-0000-000000000101"
recipient_id = "00000000-0000-0000-0000-000000000102"
expiration_time = naive_utc_now() + timedelta(hours=1)
db_session_with_containers.add(
HumanInputForm(
id=form_id,
tenant_id=str(uuid.uuid4()),
app_id=str(uuid.uuid4()),
workflow_run_id=None,
form_kind=HumanInputFormKind.DELIVERY_TEST,
node_id="human-node",
form_definition="{}",
rendered_content="content",
expiration_time=expiration_time,
)
)
db_session_with_containers.add(
HumanInputFormRecipient(
id=recipient_id,
form_id=form_id,
delivery_id=str(uuid.uuid4()),
recipient_type=StandaloneWebAppRecipientPayload().TYPE,
recipient_payload=StandaloneWebAppRecipientPayload().model_dump_json(),
access_token="form-token-1",
)
)
db_session_with_containers.commit()
return form_id, recipient_id, expiration_time
def test_issue_upload_token_returns_expiration_with_default_session_expiry(
db_session_with_containers: Session,
monkeypatch: pytest.MonkeyPatch,
) -> None:
form_id, recipient_id, expiration_time = _create_waiting_form_recipient(db_session_with_containers)
monkeypatch.setattr(service_module.secrets, "token_urlsafe", lambda _bytes: "random-value")
service = HumanInputFileUploadService(
session_factory=sessionmaker(bind=db.engine),
workflow_run_repository=MagicMock(),
)
token = service.issue_upload_token("form-token-1")
assert token.upload_token == f"{HITL_UPLOAD_TOKEN_PREFIX}random-value"
assert token.expires_at == expiration_time
db_session_with_containers.expire_all()
token_model = db_session_with_containers.scalar(select(HumanInputFormUploadToken))
assert token_model is not None
assert token_model.form_id == form_id
assert token_model.recipient_id == recipient_id
assert token_model.token == token.upload_token
@@ -3,6 +3,7 @@ from __future__ import annotations
import pytest
from sqlalchemy.orm import Session
from models.human_input import HumanInputFormStatus
from services.message_service import MessageService
from tests.test_containers_integration_tests.helpers.execution_extra_content import (
create_human_input_message_fixture,
@@ -23,17 +24,55 @@ def test_pagination_returns_extra_contents(db_session_with_containers: Session):
assert pagination.data
message = pagination.data[0]
assert message.extra_contents == [
{
"type": "human_input",
"workflow_run_id": fixture.message.workflow_run_id,
"submitted": True,
"form_submission_data": {
"node_id": fixture.form.node_id,
"node_title": fixture.node_title,
"rendered_content": fixture.form.rendered_content,
"action_id": fixture.action_id,
"action_text": fixture.action_text,
},
}
]
assert len(message.extra_contents) == 1
content = message.extra_contents[0]
assert content["type"] == "human_input"
assert content["workflow_run_id"] == fixture.message.workflow_run_id
assert content["submitted"] is True
form_submission_data = content["form_submission_data"]
assert form_submission_data["node_id"] == fixture.form.node_id
assert form_submission_data["node_title"] == fixture.node_title
assert form_submission_data["rendered_content"] == fixture.form.rendered_content
assert form_submission_data["action_id"] == fixture.action_id
assert form_submission_data["action_text"] == fixture.action_text
form_definition = content["form_definition"]
assert form_definition["form_id"] == fixture.form.id
assert form_definition["node_id"] == fixture.form.node_id
assert form_definition["node_title"] == fixture.node_title
assert form_definition["form_content"] == fixture.form.rendered_content
@pytest.mark.usefixtures("flask_req_ctx_with_containers")
def test_pagination_returns_waiting_human_input_extra_contents(db_session_with_containers: Session):
fixture = create_human_input_message_fixture(db_session_with_containers)
fixture.form.status = HumanInputFormStatus.WAITING
fixture.form.selected_action_id = None
fixture.form.submitted_at = None
fixture.form.submitted_data = None
db_session_with_containers.commit()
pagination = MessageService.pagination_by_first_id(
app_model=fixture.app,
user=fixture.account,
conversation_id=fixture.conversation.id,
first_id=None,
limit=10,
)
assert pagination.data
message = pagination.data[0]
assert len(message.extra_contents) == 1
content = message.extra_contents[0]
assert content["type"] == "human_input"
assert content["workflow_run_id"] == fixture.message.workflow_run_id
assert content["submitted"] is False
assert "form_submission_data" not in content
form_definition = content["form_definition"]
assert form_definition["form_id"] == fixture.form.id
assert form_definition["node_id"] == fixture.form.node_id
assert form_definition["node_title"] == fixture.node_title
assert form_definition["form_content"] == fixture.form.rendered_content
assert form_definition["display_in_ui"] is True
@@ -4,6 +4,7 @@ from decimal import Decimal
import pytest
from libs.helper import to_timestamp
from models.enums import ConversationFromSource
from models.model import Message
from services import message_service
@@ -47,17 +48,37 @@ def test_attach_message_extra_contents_assigns_serialized_payload(db_session_wit
message_service.attach_message_extra_contents(messages)
form = fixture.form
assert messages[0].extra_contents == [
{
"type": "human_input",
"workflow_run_id": fixture.message.workflow_run_id,
"submitted": True,
"form_definition": {
"form_id": form.id,
"node_id": form.node_id,
"node_title": "Approval",
"form_content": "Rendered block",
"inputs": [],
"actions": [
{
"id": "approve",
"title": "Approve request",
"button_style": "default",
}
],
"display_in_ui": True,
"resolved_default_values": {},
"expiration_time": to_timestamp(form.expiration_time),
},
"form_submission_data": {
"node_id": fixture.form.node_id,
"node_title": fixture.node_title,
"rendered_content": fixture.form.rendered_content,
"action_id": fixture.action_id,
"action_text": fixture.action_text,
"submitted_data": {"name": "Alice"},
},
}
]
@@ -0,0 +1,174 @@
from __future__ import annotations
from collections.abc import Sequence
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import override
from uuid import uuid4
from sqlalchemy import Engine, delete
from sqlalchemy.orm import Session, sessionmaker
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 graphon.entities.pause_reason import HumanInputRequired
from graphon.enums import WorkflowExecutionStatus
from graphon.nodes.human_input.entities import SelectInputConfig, StringListSource, UserActionConfig
from graphon.nodes.human_input.enums import HumanInputFormStatus, ValueSourceType
from graphon.runtime import GraphRuntimeState, VariablePool
from models.enums import CreatorUserRole
from models.human_input import HumanInputForm
from models.model import AppMode
from models.workflow import WorkflowRun
from repositories.entities.workflow_pause import WorkflowPauseEntity
from services.workflow_event_snapshot_service import _build_snapshot_events
@dataclass(frozen=True)
class _FakePauseEntity(WorkflowPauseEntity):
pause_id: str
workflow_run_id: str
paused_at_value: datetime
pause_reasons: Sequence[HumanInputRequired]
@property
@override
def id(self) -> str:
return self.pause_id
@property
@override
def workflow_execution_id(self) -> str:
return self.workflow_run_id
@override
def get_state(self) -> bytes:
raise AssertionError("state is not required for snapshot tests")
@property
@override
def resumed_at(self) -> datetime | None:
return None
@property
@override
def paused_at(self) -> datetime:
return self.paused_at_value
@override
def get_pause_reasons(self) -> Sequence[HumanInputRequired]:
return self.pause_reasons
def _build_resumption_context(workflow_run_id: str) -> WorkflowResumptionContext:
app_config = WorkflowUIBasedAppConfig(
tenant_id=str(uuid4()),
app_id=str(uuid4()),
app_mode=AppMode.WORKFLOW,
workflow_id=str(uuid4()),
)
generate_entity = WorkflowAppGenerateEntity(
task_id="task-1",
app_config=app_config,
inputs={},
files=[],
user_id=str(uuid4()),
stream=True,
invoke_from=InvokeFrom.EXPLORE,
call_depth=0,
workflow_execution_id=workflow_run_id,
)
runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0)
runtime_state.variable_pool.add(("start", "options"), ["approve", "reject"])
wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity)
return WorkflowResumptionContext(
generate_entity=wrapper,
serialized_graph_runtime_state=runtime_state.dumps(),
)
def _build_workflow_run(workflow_run_id: str) -> WorkflowRun:
return WorkflowRun(
id=workflow_run_id,
tenant_id=str(uuid4()),
app_id=str(uuid4()),
workflow_id=str(uuid4()),
type="workflow",
triggered_from="app-run",
version="v1",
graph=None,
inputs="{}",
status=WorkflowExecutionStatus.PAUSED,
outputs="{}",
error=None,
elapsed_time=0.0,
total_tokens=0,
total_steps=0,
created_by_role=CreatorUserRole.END_USER,
created_by=str(uuid4()),
created_at=datetime(2024, 1, 1, tzinfo=UTC),
)
def test_build_snapshot_events_resolves_variable_select_options(db_session_with_containers: Session) -> None:
engine = db_session_with_containers.get_bind()
assert isinstance(engine, Engine)
test_tenant_id = str(uuid4())
test_app_id = str(uuid4())
workflow_run_id = str(uuid4())
form = HumanInputForm(
tenant_id=test_tenant_id,
app_id=test_app_id,
workflow_run_id=workflow_run_id,
node_id="node-id",
form_definition='{"display_in_ui": true}',
rendered_content="Rendered",
status=HumanInputFormStatus.WAITING,
expiration_time=(datetime.now(UTC) + timedelta(hours=1)).replace(tzinfo=None),
)
db_session_with_containers.add(form)
db_session_with_containers.commit()
db_session_with_containers.refresh(form)
reason = HumanInputRequired(
form_id=form.id,
form_content="Rendered",
inputs=[
SelectInputConfig(
output_variable_name="decision",
option_source=StringListSource(
type=ValueSourceType.VARIABLE,
selector=["start", "options"],
value=[],
),
)
],
actions=[UserActionConfig(id="approve", title="Approve")],
node_id="node-id",
node_title="Human Input",
)
pause_entity = _FakePauseEntity(
pause_id=str(uuid4()),
workflow_run_id=workflow_run_id,
paused_at_value=datetime.now(UTC),
pause_reasons=[reason],
)
session_maker = sessionmaker(bind=engine, expire_on_commit=False)
events = _build_snapshot_events(
workflow_run=_build_workflow_run(workflow_run_id),
node_snapshots=[],
task_id="task-1",
message_context=None,
pause_entity=pause_entity,
resumption_context=_build_resumption_context(workflow_run_id),
session_maker=session_maker,
)
human_input_events = [event for event in events if event.get("event") == "human_input_required"]
assert len(human_input_events) == 1
assert human_input_events[0]["data"]["inputs"][0]["option_source"]["value"] == ["approve", "reject"]
db_session_with_containers.execute(delete(HumanInputForm).where(HumanInputForm.id == form.id))
db_session_with_containers.commit()
@@ -13,7 +13,6 @@ from controllers.web.error import NotFoundError
from graphon.entities.pause_reason import HumanInputRequired
from graphon.enums import WorkflowExecutionStatus
from graphon.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig
from graphon.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
@@ -66,7 +65,7 @@ def test_pause_details_returns_backstage_input_url(app: Flask, monkeypatch: pyte
reason = HumanInputRequired(
form_id="form-1",
form_content="content",
inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="name")],
inputs=[ParagraphInputConfig(output_variable_name="name")],
actions=[UserActionConfig(id="approve", title="Approve")],
node_id="node-1",
node_title="Ask Name",
@@ -593,7 +593,11 @@ class TestHitlServiceApi:
form_id="form-1",
form_content="Rendered",
inputs=[
ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="field", default=None),
ParagraphInputConfig(
type=FormInputType.PARAGRAPH,
output_variable_name="field",
default=None,
),
],
actions=[UserActionConfig(id="approve", title="Approve")],
display_in_ui=True,
@@ -607,7 +611,7 @@ class TestHitlServiceApi:
paused_nodes=["node-id"],
)
runtime_state = SimpleNamespace(total_tokens=0, node_run_steps=0)
runtime_state = SimpleNamespace(total_tokens=0, node_run_steps=0, variable_pool=VariablePool())
responses = converter.workflow_pause_to_stream_response(
event=queue_event,
task_id="task",
@@ -12,6 +12,7 @@ import pytest
from flask import Flask
from werkzeug.exceptions import NotFound
from controllers.common.human_input import HumanInputFormSubmitPayload
from controllers.service_api.app.human_input_form import WorkflowHumanInputFormApi
from models.human_input import RecipientType
from tests.unit_tests.controllers.service_api.conftest import _unwrap
@@ -20,7 +21,7 @@ from tests.unit_tests.controllers.service_api.conftest import _unwrap
class TestWorkflowHumanInputFormApi:
def test_get_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
definition = SimpleNamespace(
model_dump=lambda: {
model_dump=lambda **_kwargs: {
"rendered_content": "Rendered form content",
"inputs": [{"output_variable_name": "name"}],
"default_values": {"name": "Alice", "age": 30, "meta": {"k": "v"}},
@@ -36,6 +37,9 @@ class TestWorkflowHumanInputFormApi:
)
service_mock = Mock()
service_mock.get_form_by_token.return_value = form
service_mock.resolve_form_inputs.return_value = [
SimpleNamespace(model_dump=lambda **_kwargs: {"output_variable_name": "name"})
]
workflow_module = sys.modules["controllers.service_api.app.human_input_form"]
monkeypatch.setattr(workflow_module, "HumanInputService", lambda _engine: service_mock)
monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object()))
@@ -56,8 +60,54 @@ class TestWorkflowHumanInputFormApi:
"expiration_time": int(form.expiration_time.timestamp()),
}
service_mock.get_form_by_token.assert_called_once_with("token-1")
service_mock.resolve_form_inputs.assert_called_once_with(form)
service_mock.ensure_form_active.assert_called_once_with(form)
def test_get_resolves_runtime_select_values(self, app, monkeypatch: pytest.MonkeyPatch) -> None:
definition = SimpleNamespace(
model_dump=lambda **_kwargs: {
"rendered_content": "Rendered form content",
"inputs": [
{
"output_variable_name": "decision",
"option_source": {"type": "variable", "selector": ["start", "options"], "value": []},
}
],
"default_values": {},
"user_actions": [{"id": "approve", "title": "Approve"}],
}
)
form = SimpleNamespace(
app_id="app-1",
tenant_id="tenant-1",
recipient_type=RecipientType.STANDALONE_WEB_APP,
expiration_time=datetime(2099, 1, 1, tzinfo=UTC),
get_definition=lambda: definition,
)
resolved_input = SimpleNamespace(
model_dump=lambda **_kwargs: {
"output_variable_name": "decision",
"option_source": {"type": "variable", "selector": ["start", "options"], "value": ["approve", "reject"]},
}
)
service_mock = Mock()
service_mock.get_form_by_token.return_value = form
service_mock.resolve_form_inputs.return_value = [resolved_input]
workflow_module = sys.modules["controllers.service_api.app.human_input_form"]
monkeypatch.setattr(workflow_module, "HumanInputService", lambda _engine: service_mock)
monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object()))
api = WorkflowHumanInputFormApi()
handler = _unwrap(api.get)
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
with app.test_request_context("/form/human_input/token-1", method="GET"):
response = handler(api, app_model=app_model, form_token="token-1")
payload = json.loads(response.get_data(as_text=True))
assert payload["inputs"][0]["option_source"]["value"] == ["approve", "reject"]
service_mock.resolve_form_inputs.assert_called_once_with(form)
def test_get_form_not_in_app(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
form = SimpleNamespace(
app_id="another-app",
@@ -146,6 +196,71 @@ class TestWorkflowHumanInputFormApi:
submission_end_user_id="end-user-1",
)
def test_post_accepts_select_file_and_file_list_inputs(self, app, monkeypatch: pytest.MonkeyPatch) -> None:
form = SimpleNamespace(
app_id="app-1",
tenant_id="tenant-1",
recipient_type=RecipientType.STANDALONE_WEB_APP,
)
service_mock = Mock()
service_mock.get_form_by_token.return_value = form
workflow_module = sys.modules["controllers.service_api.app.human_input_form"]
monkeypatch.setattr(workflow_module, "HumanInputService", lambda _engine: service_mock)
monkeypatch.setattr(workflow_module, "db", SimpleNamespace(engine=object()))
api = WorkflowHumanInputFormApi()
handler = _unwrap(api.post)
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
end_user = SimpleNamespace(id="end-user-1")
inputs = {
"decision": "approve",
"attachment": {
"transfer_method": "local_file",
"upload_file_id": "4e0d1b87-52f2-49f6-b8c6-95cd9c954b3e",
"type": "document",
},
"attachments": [
{
"transfer_method": "local_file",
"upload_file_id": "1a77f0df-c0e6-461c-987c-e72526f341ee",
"type": "document",
},
{
"transfer_method": "remote_url",
"url": "https://example.com/report.pdf",
"type": "document",
},
],
}
with app.test_request_context(
"/form/human_input/token-1",
method="POST",
json={"inputs": inputs, "action": "approve", "user": "external-1"},
):
response, status = handler(api, app_model=app_model, end_user=end_user, form_token="token-1")
assert response == {}
assert status == 200
service_mock.submit_form_by_token.assert_called_once_with(
recipient_type=RecipientType.STANDALONE_WEB_APP,
form_token="token-1",
selected_action_id="approve",
form_data=inputs,
submission_end_user_id="end-user-1",
)
def test_submit_payload_schema_documents_select_file_and_file_list_inputs(self) -> None:
schema = HumanInputFormSubmitPayload.model_json_schema()
inputs_schema = schema["properties"]["inputs"]
assert "select input" in inputs_schema["description"]
examples = inputs_schema["examples"]
assert examples[0]["decision"] == "approve"
assert examples[0]["attachment"]["transfer_method"] == "local_file"
assert examples[0]["attachment"]["upload_file_id"] == "4e0d1b87-52f2-49f6-b8c6-95cd9c954b3e"
assert examples[0]["attachments"][1]["transfer_method"] == "remote_url"
@pytest.mark.parametrize(
"recipient_type",
[
@@ -0,0 +1,217 @@
"""Unit tests for HITL human input file upload endpoints."""
from __future__ import annotations
from datetime import datetime
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from flask import Flask
import controllers.web.human_input_file_upload as upload_module
from controllers.common.errors import NoFileUploadedError
from controllers.web.human_input_file_upload import (
HumanInputFileUploadApi,
InvalidUploadTokenForbiddenError,
InvalidUploadTokenUnauthorizedError,
)
@pytest.fixture
def app() -> Flask:
app = Flask(__name__)
app.config["TESTING"] = True
return app
def _upload_context() -> SimpleNamespace:
return SimpleNamespace(
form_id="form-1",
upload_token_id="token-row-1",
owner=SimpleNamespace(id="owner-1", current_tenant_id="tenant-1"),
)
def _upload_file() -> SimpleNamespace:
return SimpleNamespace(
id="file-1",
name="sample.txt",
size=7,
extension="txt",
mime_type="text/plain",
created_by="end-user-1",
created_at=datetime(2024, 1, 1),
tenant_id="tenant-1",
source_url="signed-source-url",
)
def _patch_upload_service(monkeypatch: pytest.MonkeyPatch, service: MagicMock) -> tuple[MagicMock, dict[str, object]]:
workflow_run_repository = MagicMock()
repo_factory = MagicMock(return_value=workflow_run_repository)
captured: dict[str, object] = {}
def _service_factory(session_factory, workflow_run_repository):
captured["session_factory"] = session_factory
captured["workflow_run_repository"] = workflow_run_repository
return service
monkeypatch.setattr(
upload_module.DifyAPIRepositoryFactory,
"create_api_workflow_run_repository",
repo_factory,
)
monkeypatch.setattr(upload_module, "HumanInputFileUploadService", _service_factory)
return repo_factory, captured
def test_human_input_file_upload_route_uses_unified_path() -> None:
urls = {
url for _resource, resource_urls, _route_doc, _kwargs in upload_module.web_ns.resources for url in resource_urls
}
assert "/human-input-forms/files" in urls
assert "/form/human_input/files/upload" not in urls
assert "/form/human_input/files/remote-upload" not in urls
def test_local_upload_requires_authorization_before_reading_files(app: Flask) -> None:
data = {"file": (BytesIO(b"content"), "sample.txt")}
with app.test_request_context(
"/api/human-input-forms/files",
method="POST",
data=data,
content_type="multipart/form-data",
):
with pytest.raises(InvalidUploadTokenUnauthorizedError):
HumanInputFileUploadApi().post()
def test_local_upload_ignores_source_and_records_form_file_link(monkeypatch: pytest.MonkeyPatch, app: Flask) -> None:
service = MagicMock()
service.validate_upload_token.return_value = _upload_context()
repo_factory, captured = _patch_upload_service(monkeypatch, service)
file_service = MagicMock()
file_service.upload_file.return_value = _upload_file()
file_service_cls = MagicMock(return_value=file_service)
monkeypatch.setattr(upload_module, "FileService", file_service_cls)
monkeypatch.setattr(upload_module, "db", SimpleNamespace(engine=object()))
data = {
"file": (BytesIO(b"content"), "sample.txt"),
"source": "datasets",
}
with app.test_request_context(
"/api/human-input-forms/files",
method="POST",
headers={"Authorization": "bearer hitl_upload_token-1"},
data=data,
content_type="multipart/form-data",
):
result, status = HumanInputFileUploadApi().post()
assert status == 201
assert result["id"] == "file-1"
file_service.upload_file.assert_called_once()
assert file_service.upload_file.call_args.kwargs["source"] is None
assert file_service.upload_file.call_args.kwargs["user"].id == "owner-1"
repo_factory.assert_called_once()
assert captured["workflow_run_repository"] is repo_factory.return_value
service.record_upload_file.assert_called_once_with(
context=service.validate_upload_token.return_value,
file_id="file-1",
)
def test_local_upload_missing_file_raises_after_valid_token(monkeypatch: pytest.MonkeyPatch, app: Flask) -> None:
service = MagicMock()
service.validate_upload_token.return_value = _upload_context()
_patch_upload_service(monkeypatch, service)
monkeypatch.setattr(upload_module, "db", SimpleNamespace(engine=object()))
with app.test_request_context(
"/api/human-input-forms/files",
method="POST",
headers={"Authorization": "bearer hitl_upload_token-1"},
content_type="multipart/form-data",
):
with pytest.raises(NoFileUploadedError):
HumanInputFileUploadApi().post()
service.validate_upload_token.assert_called_once_with("hitl_upload_token-1")
def test_remote_upload_validates_token_before_fetching_remote_url(monkeypatch: pytest.MonkeyPatch, app: Flask) -> None:
service = MagicMock()
service.validate_upload_token.side_effect = InvalidUploadTokenForbiddenError()
_patch_upload_service(monkeypatch, service)
monkeypatch.setattr(upload_module, "db", SimpleNamespace(engine=object()))
ssrf_proxy = MagicMock()
monkeypatch.setattr(upload_module, "ssrf_proxy", ssrf_proxy)
with app.test_request_context(
"/api/human-input-forms/files",
method="POST",
headers={"Authorization": "Bearer hitl_upload_token-1"},
data={"url": "https://example.com/file.txt"},
content_type="multipart/form-data",
):
with pytest.raises(InvalidUploadTokenForbiddenError):
HumanInputFileUploadApi().post()
ssrf_proxy.head.assert_not_called()
ssrf_proxy.get.assert_not_called()
def test_remote_upload_records_form_file_link(monkeypatch: pytest.MonkeyPatch, app: Flask) -> None:
service = MagicMock()
service.validate_upload_token.return_value = _upload_context()
_patch_upload_service(monkeypatch, service)
monkeypatch.setattr(upload_module, "db", SimpleNamespace(engine=object()))
response = MagicMock()
response.status_code = 200
response.content = b"remote"
response.request.method = "GET"
ssrf_proxy = MagicMock()
ssrf_proxy.head.return_value = response
monkeypatch.setattr(upload_module, "ssrf_proxy", ssrf_proxy)
monkeypatch.setattr(
upload_module.helpers,
"guess_file_info_from_response",
lambda _response: SimpleNamespace(filename="sample.txt", extension="txt", mimetype="text/plain", size=6),
)
file_service = MagicMock()
file_service.upload_file.return_value = _upload_file()
file_service_cls = MagicMock(return_value=file_service)
file_service_cls.is_file_size_within_limit.return_value = True
monkeypatch.setattr(upload_module, "FileService", file_service_cls)
monkeypatch.setattr(
upload_module.file_helpers,
"get_signed_file_url",
lambda upload_file_id: f"signed:{upload_file_id}",
)
with app.test_request_context(
"/api/human-input-forms/files",
method="POST",
headers={"Authorization": "Bearer hitl_upload_token-1"},
data={"url": "https://example.com/file.txt"},
content_type="multipart/form-data",
):
result, status = HumanInputFileUploadApi().post()
assert status == 201
assert result["url"] == "signed:file-1"
file_service.upload_file.assert_called_once()
assert file_service.upload_file.call_args.kwargs["source_url"] == "https://example.com/file.txt"
assert file_service.upload_file.call_args.kwargs["user"].id == "owner-1"
service.record_upload_file.assert_called_once_with(
context=service.validate_upload_token.return_value,
file_id="file-1",
)
@@ -15,10 +15,14 @@ from werkzeug.exceptions import Forbidden
import controllers.web.human_input_form as human_input_module
import controllers.web.site as site_module
from controllers.web.error import WebFormRateLimitExceededError
from graphon.nodes.human_input.entities import ParagraphInputConfig, SelectInputConfig, StringListSource
from graphon.nodes.human_input.enums import ValueSourceType
from models.human_input import RecipientType
from services.feature_service import FeatureModel
from services.human_input_service import FormExpiredError
HumanInputFormApi = human_input_module.HumanInputFormApi
HumanInputFormUploadTokenApi = human_input_module.HumanInputFormUploadTokenApi
TenantStatus = human_input_module.TenantStatus
@@ -63,7 +67,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
expiration_time = datetime(2099, 1, 1, tzinfo=UTC)
class _FakeDefinition:
def model_dump(self):
def model_dump(self, mode: str | None = None):
return {
"form_content": "Raw content",
"rendered_content": "Rendered {{#$output.name#}}",
@@ -117,6 +121,8 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
# Patch service to return fake form.
service_mock = MagicMock()
service_mock.get_form_by_token.return_value = form
resolved_input = ParagraphInputConfig(output_variable_name="name")
service_mock.resolve_form_inputs.return_value = [resolved_input]
monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock)
# Patch db session.
@@ -142,7 +148,7 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
"expiration_time",
}
assert body["form_content"] == "Rendered {{#$output.name#}}"
assert body["inputs"] == [{"type": "text", "output_variable_name": "name", "default": None}]
assert body["inputs"] == [resolved_input.model_dump(mode="json")]
assert body["resolved_default_values"] == {"name": "Alice", "age": "30", "meta": '{"k": "v"}'}
assert body["user_actions"] == [{"id": "approve", "title": "Approve", "button_style": "default"}]
assert body["expiration_time"] == int(expiration_time.timestamp())
@@ -180,13 +186,158 @@ def test_get_form_includes_site(monkeypatch: pytest.MonkeyPatch, app: Flask):
limiter_mock.increment_rate_limit.assert_called_once_with("203.0.113.10")
def test_get_form_uses_runtime_select_options(monkeypatch: pytest.MonkeyPatch, app: Flask):
"""GET returns variable-backed select options resolved from runtime state."""
expiration_time = datetime(2099, 1, 1, tzinfo=UTC)
configured_inputs = [
{
"type": "select",
"output_variable_name": "decision",
"option_source": {
"type": "variable",
"selector": ["start", "options"],
"value": ["configured"],
},
}
]
runtime_inputs = [
SelectInputConfig(
output_variable_name="decision",
option_source=StringListSource(
type=ValueSourceType.VARIABLE,
selector=["start", "options"],
value=["approve", "reject"],
),
)
]
class _FakeDefinition:
def model_dump(self, mode: str | None = None):
return {
"form_content": "Raw content",
"rendered_content": "Rendered",
"inputs": configured_inputs,
"default_values": {},
"user_actions": [],
}
class _FakeForm:
def __init__(self, expiration: datetime):
self.workflow_run_id = "workflow-1"
self.app_id = "app-1"
self.tenant_id = "tenant-1"
self.recipient_type = RecipientType.STANDALONE_WEB_APP
self.expiration_time = expiration
def get_definition(self):
return _FakeDefinition()
limiter_mock = MagicMock()
limiter_mock.is_rate_limited.return_value = False
monkeypatch.setattr(human_input_module, "_FORM_ACCESS_RATE_LIMITER", limiter_mock)
monkeypatch.setattr(human_input_module, "extract_remote_ip", lambda req: "203.0.113.10")
tenant = SimpleNamespace(
id="tenant-1",
status=TenantStatus.NORMAL,
plan="basic",
custom_config_dict={},
)
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", tenant=tenant, enable_site=True)
site_model = SimpleNamespace(
title="My Site",
icon_type="emoji",
icon="robot",
icon_background="#fff",
description="desc",
default_language="en",
chat_color_theme="light",
chat_color_theme_inverted=False,
copyright=None,
privacy_policy=None,
custom_disclaimer=None,
prompt_public=False,
show_workflow_steps=True,
use_icon_as_answer_icon=False,
)
form = _FakeForm(expiration_time)
service_mock = MagicMock()
service_mock.get_form_by_token.return_value = form
service_mock.resolve_form_inputs.return_value = runtime_inputs
monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock)
monkeypatch.setattr(human_input_module, "db", _FakeDB(_FakeSession({"App": app_model, "Site": site_model})))
def mock_get_features(tenant_id: str, exclude_vector_space: bool = False):
return FeatureModel(can_replace_logo=True)
monkeypatch.setattr(site_module.FeatureService, "get_features", mock_get_features)
with app.test_request_context("/api/form/human_input/token-1", method="GET"):
response = HumanInputFormApi().get("token-1")
body = json.loads(response.get_data(as_text=True))
assert body["inputs"] == [input_config.model_dump(mode="json") for input_config in runtime_inputs]
service_mock.resolve_form_inputs.assert_called_once_with(form)
def test_create_upload_token_returns_token_and_form_expiration(monkeypatch: pytest.MonkeyPatch, app: Flask):
"""POST returns a HITL upload token for an active form token."""
expiration_time = datetime(2099, 1, 1, tzinfo=UTC)
service_mock = MagicMock()
service_mock.issue_upload_token.return_value = SimpleNamespace(
upload_token="hitl_upload_token-1",
expires_at=expiration_time,
)
workflow_run_repository = MagicMock()
repo_factory = MagicMock(return_value=workflow_run_repository)
captured: dict[str, object] = {}
def _service_factory(session_factory, workflow_run_repository):
captured["session_factory"] = session_factory
captured["workflow_run_repository"] = workflow_run_repository
return service_mock
monkeypatch.setattr(
human_input_module.DifyAPIRepositoryFactory,
"create_api_workflow_run_repository",
repo_factory,
)
monkeypatch.setattr(
human_input_module,
"HumanInputFileUploadService",
_service_factory,
)
monkeypatch.setattr(human_input_module, "db", SimpleNamespace(engine=object()))
limiter_mock = MagicMock()
limiter_mock.is_rate_limited.return_value = False
monkeypatch.setattr(human_input_module, "_FORM_UPLOAD_TOKEN_RATE_LIMITER", limiter_mock)
monkeypatch.setattr(human_input_module, "extract_remote_ip", lambda req: "203.0.113.10")
with app.test_request_context("/api/form/human_input/token-1/upload-token", method="POST"):
result, status = HumanInputFormUploadTokenApi().post("token-1")
assert status == 200
assert result == {
"upload_token": "hitl_upload_token-1",
"expires_at": int(expiration_time.timestamp()),
}
repo_factory.assert_called_once()
assert captured["workflow_run_repository"] is workflow_run_repository
service_mock.issue_upload_token.assert_called_once_with("token-1")
limiter_mock.increment_rate_limit.assert_called_once_with("203.0.113.10")
def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: Flask):
"""GET returns form payload for backstage token."""
expiration_time = datetime(2099, 1, 2, tzinfo=UTC)
class _FakeDefinition:
def model_dump(self):
def model_dump(self, mode: str | None = None):
return {
"form_content": "Raw content",
"rendered_content": "Rendered",
@@ -237,6 +388,7 @@ def test_get_form_allows_backstage_token(monkeypatch: pytest.MonkeyPatch, app: F
service_mock = MagicMock()
service_mock.get_form_by_token.return_value = form
service_mock.resolve_form_inputs.return_value = []
monkeypatch.setattr(human_input_module, "HumanInputService", lambda engine: service_mock)
db_stub = _FakeDB(_FakeSession({"WorkflowRun": workflow_run, "App": app_model, "Site": site_model}))
@@ -305,7 +457,7 @@ def test_get_form_raises_forbidden_when_site_missing(monkeypatch: pytest.MonkeyP
expiration_time = datetime(2099, 1, 3, tzinfo=UTC)
class _FakeDefinition:
def model_dump(self):
def model_dump(self, mode: str | None = None):
return {
"form_content": "Raw content",
"rendered_content": "Rendered",
@@ -7,6 +7,7 @@ from core.app.entities.queue_entities import QueueHumanInputFormFilledEvent, Que
from core.workflow.system_variables import build_system_variables
from graphon.entities import WorkflowStartReason
from graphon.runtime import GraphRuntimeState, VariablePool
from graphon.variables.segments import StringSegment
def _build_converter():
@@ -63,6 +64,37 @@ def test_human_input_form_filled_stream_response_contains_rendered_content():
assert resp.data.action_id == "Approve"
def test_human_input_form_filled_stream_response_serializes_submitted_data():
converter = _build_converter()
converter.workflow_start_to_stream_response(
task_id="task-1",
workflow_run_id="run-1",
workflow_id="wf-1",
reason=WorkflowStartReason.INITIAL,
)
queue_event = QueueHumanInputFormFilledEvent(
node_execution_id="exec-1",
node_id="node-1",
node_type="human-input",
node_title="Human Input",
rendered_content="# Title\nvalue",
action_id="Approve",
action_text="Approve",
submitted_data={
"decision": StringSegment(value="approve"),
"comment": StringSegment(value="looks good"),
},
)
resp = converter.human_input_form_filled_to_stream_response(event=queue_event, task_id="task-1")
assert resp.data.submitted_data == {
"decision": "approve",
"comment": "looks good",
}
def test_human_input_form_timeout_stream_response_contains_timeout_metadata():
converter = _build_converter()
converter.workflow_start_to_stream_response(
@@ -9,6 +9,7 @@ from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
from core.app.entities.queue_entities import (
QueueAgentLogEvent,
QueueHumanInputFormFilledEvent,
QueueIterationCompletedEvent,
QueueLoopCompletedEvent,
QueueNodeExceptionEvent,
@@ -30,6 +31,7 @@ from graphon.graph_events import (
NodeRunAgentLogEvent,
NodeRunExceptionEvent,
NodeRunFailedEvent,
NodeRunHumanInputFormFilledEvent,
NodeRunIterationSucceededEvent,
NodeRunLoopFailedEvent,
NodeRunRetryEvent,
@@ -39,6 +41,7 @@ from graphon.graph_events import (
)
from graphon.node_events import NodeRunResult
from graphon.runtime import GraphRuntimeState, VariablePool
from graphon.variables.segments import StringSegment
from graphon.variables.variables import StringVariable
@@ -363,6 +366,42 @@ class TestWorkflowBasedAppRunner:
assert any(isinstance(event, QueueIterationCompletedEvent) for event in published)
assert any(isinstance(event, QueueLoopCompletedEvent) for event in published)
def test_handle_human_input_form_filled_event_preserves_submitted_data(self):
published: list[object] = []
class _QueueManager:
def publish(self, event, publish_from):
published.append(event)
runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app")
graph_runtime_state = GraphRuntimeState(
variable_pool=VariablePool.from_bootstrap(
system_variables=default_system_variables(),
user_inputs={},
environment_variables=[],
),
start_at=0.0,
)
workflow_entry = SimpleNamespace(graph_engine=SimpleNamespace(graph_runtime_state=graph_runtime_state))
runner._handle_event(
workflow_entry,
NodeRunHumanInputFormFilledEvent(
id="exec",
node_id="node",
node_type=BuiltinNodeTypes.HUMAN_INPUT,
node_title="Human Input",
rendered_content="content",
action_id="approve",
action_text="Approve",
submitted_data={"decision": StringSegment(value="approve")},
),
)
queue_event = published[-1]
assert isinstance(queue_event, QueueHumanInputFormFilledEvent)
assert queue_event.submitted_data == {"decision": StringSegment(value="approve")}
@pytest.mark.parametrize(
("event_factory", "queue_event_cls"),
[
@@ -14,8 +14,14 @@ from core.workflow.system_variables import build_system_variables
from graphon.entities import WorkflowStartReason
from graphon.entities.pause_reason import HumanInputRequired
from graphon.graph_events import GraphRunPausedEvent
from graphon.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig
from graphon.nodes.human_input.enums import FormInputType
from graphon.nodes.human_input.entities import (
ParagraphInputConfig,
SelectInputConfig,
StringListSource,
UserActionConfig,
)
from graphon.nodes.human_input.enums import ValueSourceType
from graphon.runtime import GraphRuntimeState, VariablePool
from models.account import Account
from models.human_input import RecipientType
@@ -156,9 +162,7 @@ def test_queue_workflow_paused_event_to_stream_responses(monkeypatch: pytest.Mon
reason = HumanInputRequired(
form_id="form-1",
form_content="Rendered",
inputs=[
ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="field", default=None),
],
inputs=[ParagraphInputConfig(output_variable_name="field")],
actions=[UserActionConfig(id="approve", title="Approve")],
node_id="node-id",
node_title="Human Step",
@@ -169,7 +173,7 @@ def test_queue_workflow_paused_event_to_stream_responses(monkeypatch: pytest.Mon
paused_nodes=["node-id"],
)
runtime_state = SimpleNamespace(total_tokens=0, node_run_steps=0)
runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0)
responses = converter.workflow_pause_to_stream_response(
event=queue_event,
task_id="task",
@@ -193,3 +197,70 @@ def test_queue_workflow_paused_event_to_stream_responses(monkeypatch: pytest.Mon
assert hi_resp.data.display_in_ui is True
assert hi_resp.data.form_token == "backstage-token"
assert hi_resp.data.expiration_time == int(expiration_time.timestamp())
def test_queue_workflow_paused_event_resolves_variable_select_options(monkeypatch: pytest.MonkeyPatch):
converter = _build_converter()
converter.workflow_start_to_stream_response(
task_id="task",
workflow_run_id="run-id",
workflow_id="workflow-id",
reason=WorkflowStartReason.INITIAL,
)
expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
class _FakeSession:
def execute(self, _stmt):
return [("form-1", expiration_time, '{"display_in_ui": true}')]
def scalars(self, _stmt):
return []
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: _FakeSession())
monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object()))
reason = HumanInputRequired(
form_id="form-1",
form_content="Rendered",
inputs=[
SelectInputConfig(
output_variable_name="decision",
option_source=StringListSource(
type=ValueSourceType.VARIABLE,
selector=["start", "options"],
value=[],
),
)
],
actions=[UserActionConfig(id="approve", title="Approve")],
node_id="node-id",
node_title="Human Step",
)
queue_event = QueueWorkflowPausedEvent(
reasons=[reason],
outputs={},
paused_nodes=["node-id"],
)
runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0)
runtime_state.variable_pool.add(("start", "options"), ["approve", "reject"])
responses = converter.workflow_pause_to_stream_response(
event=queue_event,
task_id="task",
graph_runtime_state=runtime_state,
)
assert isinstance(responses[0], HumanInputRequiredResponse)
hi_resp = responses[0]
assert hi_resp.data.inputs[0].option_source.value == ["approve", "reject"]
assert isinstance(responses[-1], WorkflowPauseStreamResponse)
pause_resp = responses[-1]
assert pause_resp.data.reasons[0]["inputs"][0]["option_source"]["value"] == ["approve", "reject"]
@@ -5,7 +5,6 @@ from core.entities.execution_extra_content import (
HumanInputFormSubmissionData,
)
from graphon.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig
from graphon.nodes.human_input.enums import FormInputType
from models.execution_extra_content import ExecutionContentType
@@ -16,7 +15,7 @@ def test_human_input_content_defaults_and_domain_alias() -> None:
node_id="node-1",
node_title="Human Input",
form_content="Please confirm",
inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="answer")],
inputs=[ParagraphInputConfig(output_variable_name="answer")],
actions=[UserActionConfig(id="confirm", title="Confirm")],
resolved_default_values={"answer": "yes"},
expiration_time=1_700_000_000,
@@ -27,6 +26,7 @@ def test_human_input_content_defaults_and_domain_alias() -> None:
rendered_content="Please confirm",
action_id="confirm",
action_text="Confirm",
submitted_data={"answer": "yes"},
)
# Act
@@ -42,4 +42,5 @@ def test_human_input_content_defaults_and_domain_alias() -> None:
assert content.type == ExecutionContentType.HUMAN_INPUT
assert content.form_definition is form_definition
assert content.form_submission_data is submission_data
assert content.form_submission_data.submitted_data == {"answer": "yes"}
assert ExecutionExtraContentDomainModel is HumanInputContent
@@ -586,6 +586,73 @@ def test_mark_submitted_updates_and_raises_when_missing(monkeypatch: pytest.Monk
assert record.submitted_data == {"k": "v"}
def test_mark_submitted_serializes_select_and_file_payloads(monkeypatch: pytest.MonkeyPatch) -> None:
fixed_now = datetime(2024, 1, 1, 0, 0, 0)
monkeypatch.setattr("core.repositories.human_input_repository.naive_utc_now", lambda: fixed_now)
form = _DummyForm(
id="f-complex",
workflow_run_id=None,
node_id="node",
tenant_id="tenant",
app_id="app",
form_definition=_make_form_definition_json(include_expiration_time=True),
rendered_content="<p>x</p>",
expiration_time=fixed_now,
)
recipient = _DummyRecipient(
id="r-complex",
form_id=form.id,
recipient_type=RecipientType.CONSOLE,
access_token="tok",
)
session = _FakeSession(forms={form.id: form}, recipients={recipient.id: recipient})
_patch_session_factory(monkeypatch, session)
payload = {
"decision": "approve",
"attachment": {
"type": "document",
"transfer_method": "remote_url",
"remote_url": "https://example.com/file.txt",
"filename": "file.txt",
"extension": ".txt",
"mime_type": "text/plain",
},
"attachments": [
{
"type": "document",
"transfer_method": "remote_url",
"remote_url": "https://example.com/first.txt",
"filename": "first.txt",
"extension": ".txt",
"mime_type": "text/plain",
},
{
"type": "document",
"transfer_method": "remote_url",
"remote_url": "https://example.com/second.txt",
"filename": "second.txt",
"extension": ".txt",
"mime_type": "text/plain",
},
],
}
repo = HumanInputFormSubmissionRepository()
record = repo.mark_submitted(
form_id=form.id,
recipient_id=recipient.id,
selected_action_id="approve",
form_data=payload,
submission_user_id="user-1",
submission_end_user_id="end-user-1",
)
assert json.loads(form.submitted_data or "") == payload
assert record.submitted_data == payload
def test_mark_timeout_invalid_status_raises(monkeypatch: pytest.MonkeyPatch) -> None:
form = _DummyForm(
id="f",
@@ -81,7 +81,7 @@ class MockNodeMixin:
if isinstance(self, TemplateTransformNode):
kwargs.setdefault("jinja2_template_renderer", _TestJinja2Renderer())
# Provide default ToolNode dependencies for ToolNode subclasses.
# Provide default tool_file_manager for ToolNode subclasses
from graphon.nodes.tool import ToolNode as _ToolNode # local import to avoid cycles
if isinstance(self, _ToolNode):
@@ -12,6 +12,7 @@ from core.repositories.human_input_repository import (
from core.workflow.node_runtime import DifyFileReferenceFactory, DifyHumanInputNodeRuntime
from core.workflow.system_variables import build_system_variables
from graphon.entities import WorkflowStartReason
from graphon.file import File, FileTransferMethod, FileType
from graphon.graph import Graph
from graphon.graph_engine import GraphEngine, GraphEngineConfig
from graphon.graph_engine.command_channels import InMemoryChannel
@@ -24,8 +25,15 @@ from graphon.graph_events import (
from graphon.nodes.base.entities import OutputVariableEntity
from graphon.nodes.end.end_node import EndNode
from graphon.nodes.end.entities import EndNodeData
from graphon.nodes.human_input.entities import HumanInputNodeData, UserActionConfig
from graphon.nodes.human_input.enums import HumanInputFormStatus
from graphon.nodes.human_input.entities import (
FileInputConfig,
FileListInputConfig,
HumanInputNodeData,
SelectInputConfig,
StringListSource,
UserActionConfig,
)
from graphon.nodes.human_input.enums import HumanInputFormStatus, ValueSourceType
from graphon.nodes.human_input.human_input_node import HumanInputNode
from graphon.nodes.start.entities import StartNodeData
from graphon.nodes.start.start_node import StartNode
@@ -52,6 +60,21 @@ class InMemoryPauseStore:
return GraphRuntimeState.from_snapshot(self._snapshot)
class _TestFileReferenceFactory:
def build_from_mapping(self, *, mapping: Mapping[str, Any]) -> File:
return File(
file_id=mapping.get("id"),
file_type=FileType(mapping["type"]),
transfer_method=FileTransferMethod(mapping["transfer_method"]),
remote_url=mapping.get("remote_url") or mapping.get("url"),
related_id=mapping.get("related_id") or mapping.get("upload_file_id"),
filename=mapping.get("filename"),
extension=mapping.get("extension"),
mime_type=mapping.get("mime_type"),
size=mapping.get("size", -1),
)
@dataclass
class StaticForm(HumanInputFormEntity):
form_id: str
@@ -106,6 +129,9 @@ class StaticRepo(HumanInputFormRepository):
def get_form(self, node_id: str) -> HumanInputFormEntity | None:
return self._forms_by_node_id.get(node_id)
def set_forms(self, forms_by_node_id: Mapping[str, HumanInputFormEntity]) -> None:
self._forms_by_node_id = dict(forms_by_node_id)
def create_form(self, params: FormCreateParams) -> HumanInputFormEntity:
raise AssertionError("create_form should not be called in resume scenario")
@@ -148,7 +174,14 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor
human_data = HumanInputNodeData(
title="Human Input",
form_content="Human input required",
inputs=[],
inputs=[
SelectInputConfig(
output_variable_name="decision",
option_source=StringListSource(type=ValueSourceType.CONSTANT, value=["approve", "reject"]),
),
FileInputConfig(output_variable_name="attachment"),
FileListInputConfig(output_variable_name="attachments", number_limits=2),
],
user_actions=[UserActionConfig(id="approve", title="Approve")],
)
@@ -177,8 +210,12 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor
end_data = EndNodeData(
title="End",
outputs=[
OutputVariableEntity(variable="res_a", value_selector=["human_a", "__action_id"]),
OutputVariableEntity(variable="res_b", value_selector=["human_b", "__action_id"]),
OutputVariableEntity(variable="res_a_action", value_selector=["human_a", "__action_id"]),
OutputVariableEntity(variable="res_a_decision", value_selector=["human_a", "decision"]),
OutputVariableEntity(variable="res_a_attachment", value_selector=["human_a", "attachment"]),
OutputVariableEntity(variable="res_b_action", value_selector=["human_b", "__action_id"]),
OutputVariableEntity(variable="res_b_decision", value_selector=["human_b", "decision"]),
OutputVariableEntity(variable="res_b_attachments", value_selector=["human_b", "attachments"]),
],
desc=None,
)
@@ -216,13 +253,13 @@ def _run_graph(graph: Graph, runtime_state: GraphRuntimeState) -> list[object]:
return list(engine.run())
def _form(submitted: bool, action_id: str | None) -> StaticForm:
def _form(submitted: bool, action_id: str | None, data: Mapping[str, Any] | None = None) -> StaticForm:
return StaticForm(
form_id="form",
rendered="rendered",
is_submitted=submitted,
action_id=action_id,
data={},
data=data,
status_value=HumanInputFormStatus.SUBMITTED if submitted else HumanInputFormStatus.WAITING,
)
@@ -246,7 +283,21 @@ def test_parallel_human_input_join_completes_after_second_resume() -> None:
first_resume_state = pause_store.load()
first_resume_repo = StaticRepo(
{
"human_a": _form(submitted=True, action_id="approve"),
"human_a": _form(
submitted=True,
action_id="approve",
data={
"decision": "approve",
"attachment": {
"type": "document",
"transfer_method": "remote_url",
"remote_url": "https://example.com/resume.pdf",
"filename": "resume.pdf",
"extension": ".pdf",
"mime_type": "application/pdf",
},
},
),
"human_b": _form(submitted=False, action_id=None),
}
)
@@ -256,19 +307,68 @@ def test_parallel_human_input_join_completes_after_second_resume() -> None:
assert isinstance(first_resume_events[0], GraphRunStartedEvent)
assert first_resume_events[0].reason is WorkflowStartReason.RESUMPTION
assert isinstance(first_resume_events[-1], GraphRunPausedEvent)
pause_store.save(first_resume_state)
second_resume_state = pause_store.load()
second_resume_repo = StaticRepo(
second_resume_state = first_resume_state
first_resume_repo.set_forms(
{
"human_a": _form(submitted=True, action_id="approve"),
"human_b": _form(submitted=True, action_id="approve"),
"human_a": _form(
submitted=True,
action_id="approve",
data={
"decision": "approve",
"attachment": {
"type": "document",
"transfer_method": "remote_url",
"remote_url": "https://example.com/resume.pdf",
"filename": "resume.pdf",
"extension": ".pdf",
"mime_type": "application/pdf",
},
},
),
"human_b": _form(
submitted=True,
action_id="approve",
data={
"decision": "reject",
"attachments": [
{
"type": "image",
"transfer_method": "remote_url",
"remote_url": "https://example.com/a.png",
"filename": "a.png",
"extension": ".png",
"mime_type": "image/png",
},
{
"type": "image",
"transfer_method": "remote_url",
"remote_url": "https://example.com/b.png",
"filename": "b.png",
"extension": ".png",
"mime_type": "image/png",
},
],
},
),
}
)
second_resume_graph = _build_graph(second_resume_state, second_resume_repo)
second_resume_events = _run_graph(second_resume_graph, second_resume_state)
second_resume_events = _run_graph(first_resume_graph, second_resume_state)
assert isinstance(second_resume_events[0], GraphRunStartedEvent)
assert second_resume_events[0].reason is WorkflowStartReason.RESUMPTION
assert isinstance(second_resume_events[-1], GraphRunSucceededEvent)
assert any(isinstance(event, NodeRunSucceededEvent) and event.node_id == "end" for event in second_resume_events)
second_resume_outputs = second_resume_state.outputs
assert second_resume_outputs["res_a_action"] == "approve"
assert second_resume_outputs["res_a_decision"] == "approve"
assert isinstance(second_resume_outputs["res_a_attachment"], File)
res_a_attachment_in_second_outputs = second_resume_outputs["res_a_attachment"]
assert isinstance(res_a_attachment_in_second_outputs, File)
assert res_a_attachment_in_second_outputs.filename == "resume.pdf"
assert res_a_attachment_in_second_outputs.type == FileType.DOCUMENT
assert res_a_attachment_in_second_outputs.transfer_method == FileTransferMethod.REMOTE_URL
assert second_resume_outputs["res_b_action"] == "approve"
assert second_resume_outputs["res_b_decision"] == "reject"
assert isinstance(second_resume_outputs["res_b_attachments"], list)
assert [file.filename for file in second_resume_outputs["res_b_attachments"]] == ["a.png", "b.png"]
assert all(file.type == FileType.IMAGE for file in second_resume_outputs["res_b_attachments"])
@@ -33,11 +33,16 @@ from core.workflow.human_input_adapter import (
from core.workflow.node_runtime import DifyFileReferenceFactory, DifyHumanInputNodeRuntime
from core.workflow.system_variables import build_system_variables
from graphon.entities import GraphInitParams
from graphon.file import File, FileTransferMethod, FileType
from graphon.node_events import PauseRequestedEvent
from graphon.node_events.node import StreamCompletedEvent
from graphon.nodes.human_input.entities import (
FileInputConfig,
FileListInputConfig,
HumanInputNodeData,
ParagraphInputConfig,
SelectInputConfig,
StringListSource,
StringSource,
UserActionConfig,
)
@@ -49,7 +54,9 @@ from graphon.nodes.human_input.enums import (
ValueSourceType,
)
from graphon.nodes.human_input.human_input_node import HumanInputNode
from graphon.nodes.protocols import FileReferenceFactoryProtocol
from graphon.runtime import GraphRuntimeState, VariablePool
from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment
from libs.datetime_utils import naive_utc_now
@@ -136,6 +143,23 @@ class InMemoryHumanInputFormRepository(HumanInputFormRepository):
entity.status_value = HumanInputFormStatus.SUBMITTED
class _TestFileReferenceFactory(FileReferenceFactoryProtocol):
"""Build graph-layer file objects without touching Dify persistence in unit tests."""
def build_from_mapping(self, *, mapping: Mapping[str, Any]) -> File:
return File(
file_id=mapping.get("id"),
file_type=FileType(mapping["type"]),
transfer_method=FileTransferMethod(mapping["transfer_method"]),
remote_url=mapping.get("remote_url") or mapping.get("url"),
related_id=mapping.get("related_id") or mapping.get("upload_file_id"),
filename=mapping.get("filename"),
extension=mapping.get("extension"),
mime_type=mapping.get("mime_type"),
size=mapping.get("size", -1),
)
def _build_human_input_node(
*,
node_id: str,
@@ -198,12 +222,11 @@ class TestParagraphInputConfig:
"""Test paragraph input with constant default value."""
default = StringSource(type=ValueSourceType.CONSTANT, value="Enter your response here...")
form_input = ParagraphInputConfig(
type=FormInputType.PARAGRAPH, output_variable_name="user_input", default=default
)
form_input = ParagraphInputConfig(output_variable_name="user_input", default=default)
assert form_input.type == FormInputType.PARAGRAPH
assert form_input.output_variable_name == "user_input"
assert form_input.default is not None
assert form_input.default.type == ValueSourceType.CONSTANT
assert form_input.default.value == "Enter your response here..."
@@ -211,16 +234,15 @@ class TestParagraphInputConfig:
"""Test paragraph input with variable default value."""
default = StringSource(type=ValueSourceType.VARIABLE, selector=["node_123", "output_var"])
form_input = ParagraphInputConfig(
type=FormInputType.PARAGRAPH, output_variable_name="user_input", default=default
)
form_input = ParagraphInputConfig(output_variable_name="user_input", default=default)
assert form_input.default is not None
assert form_input.default.type == ValueSourceType.VARIABLE
assert form_input.default.selector == ["node_123", "output_var"]
def test_form_input_without_default(self):
"""Test form input without default value."""
form_input = ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="description")
form_input = ParagraphInputConfig(output_variable_name="description")
assert form_input.type == FormInputType.PARAGRAPH
assert form_input.output_variable_name == "description"
@@ -279,7 +301,6 @@ class TestHumanInputNodeData:
inputs = [
ParagraphInputConfig(
type=FormInputType.PARAGRAPH,
output_variable_name="content",
default=StringSource(type=ValueSourceType.CONSTANT, value="Enter content..."),
)
@@ -343,8 +364,8 @@ class TestHumanInputNodeData:
def test_duplicate_input_output_variable_name_raises_validation_error(self):
"""Duplicate form input output_variable_name should raise validation error."""
duplicate_inputs = [
ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="content"),
ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="content"),
ParagraphInputConfig(output_variable_name="content"),
ParagraphInputConfig(output_variable_name="content"),
]
with pytest.raises(ValidationError, match="duplicated output_variable_name 'content'"):
@@ -464,12 +485,10 @@ class TestHumanInputNodeVariableResolution:
form_content="Provide your name",
inputs=[
ParagraphInputConfig(
type=FormInputType.PARAGRAPH,
output_variable_name="user_name",
default=StringSource(type=ValueSourceType.VARIABLE, selector=["start", "name"]),
),
ParagraphInputConfig(
type=FormInputType.PARAGRAPH,
output_variable_name="user_email",
default=StringSource(type=ValueSourceType.CONSTANT, value="foo@example.com"),
),
@@ -726,9 +745,11 @@ class TestValidation:
def test_invalid_form_input_type(self):
"""Test validation with invalid form input type."""
with pytest.raises(ValidationError):
ParagraphInputConfig(
type="invalid-type", # Invalid type
output_variable_name="test",
ParagraphInputConfig.model_validate(
{
"type": "invalid-type",
"output_variable_name": "test",
}
)
def test_invalid_button_style(self):
@@ -782,12 +803,7 @@ class TestHumanInputNodeRenderedContent:
node_data = HumanInputNodeData(
title="Human Input",
form_content="Name: {{#$output.name#}}",
inputs=[
ParagraphInputConfig(
type=FormInputType.PARAGRAPH,
output_variable_name="name",
)
],
inputs=[ParagraphInputConfig(output_variable_name="name")],
user_actions=[UserActionConfig(id="approve", title="Approve")],
)
config = {"id": "human", "data": node_data.model_dump()}
@@ -815,4 +831,115 @@ class TestHumanInputNodeRenderedContent:
last_event = events[-1]
assert isinstance(last_event, StreamCompletedEvent)
node_run_result = last_event.node_run_result
assert node_run_result.outputs["__rendered_content"].to_object() == "Name: Alice"
assert node_run_result.outputs["name"] == StringSegment(value="Alice")
assert node_run_result.outputs["__action_id"] == StringSegment(value="approve")
assert node_run_result.outputs["__rendered_content"] == StringSegment(value="Name: Alice")
def test_resume_restores_file_outputs_as_runtime_segments(self):
variable_pool = VariablePool.from_bootstrap(
system_variables=build_system_variables(
user_id="user",
app_id="app",
workflow_id="workflow",
workflow_execution_id="run",
),
user_inputs={},
conversation_variables=[],
)
runtime_state = GraphRuntimeState(variable_pool=variable_pool, start_at=0.0)
graph_init_params = GraphInitParams(
workflow_id="workflow",
graph_config={"nodes": [], "edges": []},
run_context={
DIFY_RUN_CONTEXT_KEY: {
"tenant_id": "tenant",
"app_id": "app",
"user_id": "user",
"user_from": "account",
"invoke_from": "debugger",
}
},
call_depth=0,
)
node_data = HumanInputNodeData(
title="Human Input",
form_content=(
"Decision: {{#$output.decision#}}\n"
"Attachment: {{#$output.attachment#}}\n"
"Attachments: {{#$output.attachments#}}"
),
inputs=[
SelectInputConfig(
output_variable_name="decision",
option_source=StringListSource(type="constant", value=["approve", "reject"]),
),
FileInputConfig(output_variable_name="attachment"),
FileListInputConfig(output_variable_name="attachments", number_limits=2),
],
user_actions=[UserActionConfig(id="approve", title="Approve")],
)
config = {"id": "human", "data": node_data.model_dump()}
form_repository = InMemoryHumanInputFormRepository()
runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context)
runtime._build_form_repository = MagicMock(return_value=form_repository) # type: ignore[attr-defined]
node = _build_human_input_node(
node_id=config["id"],
node_data=config["data"],
graph_init_params=graph_init_params,
graph_runtime_state=runtime_state,
runtime=runtime,
)
pause_gen = node._run()
pause_event = next(pause_gen)
assert isinstance(pause_event, PauseRequestedEvent)
with pytest.raises(StopIteration):
next(pause_gen)
form_repository.set_submission(
action_id="approve",
form_data={
"decision": "approve",
"attachment": {
"type": "document",
"transfer_method": "remote_url",
"remote_url": "https://example.com/resume.pdf",
"filename": "resume.pdf",
"extension": ".pdf",
"mime_type": "application/pdf",
},
"attachments": [
{
"type": "image",
"transfer_method": "remote_url",
"remote_url": "https://example.com/a.png",
"filename": "a.png",
"extension": ".png",
"mime_type": "image/png",
},
{
"type": "image",
"transfer_method": "remote_url",
"remote_url": "https://example.com/b.png",
"filename": "b.png",
"extension": ".png",
"mime_type": "image/png",
},
],
},
)
events = list(node._run())
last_event = events[-1]
assert isinstance(last_event, StreamCompletedEvent)
node_run_result = last_event.node_run_result
assert node_run_result.outputs["decision"] == StringSegment(value="approve")
assert node_run_result.outputs["__rendered_content"] == StringSegment(
value="Decision: approve\nAttachment: [file]\nAttachments: [2 files]"
)
assert isinstance(node_run_result.outputs["attachment"], FileSegment)
assert node_run_result.outputs["attachment"].value.filename == "resume.pdf"
assert isinstance(node_run_result.outputs["attachments"], ArrayFileSegment)
assert [file.filename for file in node_run_result.outputs["attachments"].value] == ["a.png", "b.png"]
@@ -1,20 +1,34 @@
import datetime
from collections.abc import Mapping
from types import SimpleNamespace
from typing import Any
from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, InvokeFrom, UserFrom
from core.workflow.node_runtime import DifyFileReferenceFactory, DifyHumanInputNodeRuntime
from core.workflow.system_variables import default_system_variables
from graphon.entities import GraphInitParams
from graphon.enums import BuiltinNodeTypes
from graphon.file import File, FileTransferMethod, FileType
from graphon.graph_events import (
NodeRunHumanInputFormFilledEvent,
NodeRunHumanInputFormTimeoutEvent,
NodeRunStartedEvent,
)
from graphon.nodes.human_input.entities import HumanInputNodeData
from graphon.nodes.human_input.entities import (
FileInputConfig,
FileListInputConfig,
HumanInputNodeData,
ParagraphInputConfig,
SelectInputConfig,
StringListSource,
UserActionConfig,
)
from graphon.nodes.human_input.enums import HumanInputFormStatus
from graphon.nodes.human_input.human_input_node import HumanInputNode
from graphon.nodes.protocols import FileReferenceFactoryProtocol
from graphon.runtime import GraphRuntimeState, VariablePool
from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment
from graphon.variables.types import SegmentType
from libs.datetime_utils import naive_utc_now
@@ -26,6 +40,21 @@ class _FakeFormRepository:
return self._form
class _TestFileReferenceFactory(FileReferenceFactoryProtocol):
def build_from_mapping(self, *, mapping: Mapping[str, Any]):
return File(
file_id=mapping.get("id"),
file_type=FileType(mapping["type"]),
transfer_method=FileTransferMethod(mapping["transfer_method"]),
remote_url=mapping.get("remote_url") or mapping.get("url"),
related_id=mapping.get("related_id") or mapping.get("upload_file_id"),
filename=mapping.get("filename"),
extension=mapping.get("extension"),
mime_type=mapping.get("mime_type"),
size=mapping.get("size", -1),
)
def _create_human_input_node(
*,
config: dict,
@@ -49,7 +78,14 @@ def _create_human_input_node(
)
def _build_node(form_content: str = "Please enter your name:\n\n{{#$output.name#}}") -> HumanInputNode:
def _build_node(
form_content: str = (
"Please enter your name:\n\n{{#$output.name#}}\n"
"Decision: {{#$output.decision#}}\n"
"Attachment: {{#$output.attachment#}}\n"
"Attachments: {{#$output.attachments#}}"
),
) -> HumanInputNode:
system_variables = default_system_variables()
graph_runtime_state = GraphRuntimeState(
variable_pool=VariablePool.from_bootstrap(
@@ -81,19 +117,15 @@ def _build_node(form_content: str = "Please enter your name:\n\n{{#$output.name#
"title": "Human Input",
"form_content": form_content,
"inputs": [
{
"type": "paragraph",
"output_variable_name": "name",
"default": {"type": "constant", "value": ""},
}
],
"user_actions": [
{
"id": "Accept",
"title": "Approve",
"button_style": "default",
}
ParagraphInputConfig(output_variable_name="name").model_dump(mode="json"),
SelectInputConfig(
output_variable_name="decision",
option_source=StringListSource(type="constant", value=["approve", "reject"]),
).model_dump(mode="json"),
FileInputConfig(output_variable_name="attachment").model_dump(mode="json"),
FileListInputConfig(output_variable_name="attachments", number_limits=2).model_dump(mode="json"),
],
"user_actions": [UserActionConfig(id="Accept", title="Approve").model_dump(mode="json")],
},
}
@@ -102,7 +134,28 @@ def _build_node(form_content: str = "Please enter your name:\n\n{{#$output.name#
rendered_content=form_content,
submitted=True,
selected_action_id="Accept",
submitted_data={"name": "Alice"},
submitted_data={
"name": "Alice",
"decision": "approve",
"attachment": {
"type": "document",
"transfer_method": "remote_url",
"remote_url": "https://example.com/resume.pdf",
"filename": "resume.pdf",
"extension": ".pdf",
"mime_type": "application/pdf",
},
"attachments": [
{
"type": "image",
"transfer_method": "remote_url",
"remote_url": "https://example.com/a.png",
"filename": "a.png",
"extension": ".png",
"mime_type": "image/png",
}
],
},
status=HumanInputFormStatus.SUBMITTED,
expiration_time=naive_utc_now() + datetime.timedelta(days=1),
)
@@ -147,20 +200,8 @@ def _build_timeout_node() -> HumanInputNode:
"data": {
"title": "Human Input",
"form_content": "Please enter your name:\n\n{{#$output.name#}}",
"inputs": [
{
"type": "paragraph",
"output_variable_name": "name",
"default": {"type": "constant", "value": ""},
}
],
"user_actions": [
{
"id": "Accept",
"title": "Approve",
"button_style": "default",
}
],
"inputs": [ParagraphInputConfig(output_variable_name="name").model_dump(mode="json")],
"user_actions": [UserActionConfig(id="Accept", title="Approve").model_dump(mode="json")],
},
}
@@ -193,9 +234,22 @@ def test_human_input_node_emits_form_filled_event_before_succeeded():
filled_event = events[1]
assert filled_event.node_title == "Human Input"
assert filled_event.rendered_content.endswith("Alice")
assert filled_event.rendered_content == (
"Please enter your name:\n\nAlice\nDecision: approve\nAttachment: [file]\nAttachments: [1 files]"
)
assert filled_event.action_id == "Accept"
assert filled_event.action_text == "Approve"
assert filled_event.submitted_data["name"] == StringSegment(value="Alice")
assert filled_event.submitted_data["decision"] == StringSegment(value="approve")
assert isinstance(filled_event.submitted_data["attachment"], FileSegment)
assert filled_event.submitted_data["attachment"].value_type == SegmentType.FILE
assert filled_event.submitted_data["attachment"].value.filename == "resume.pdf"
assert filled_event.submitted_data["attachment"].value.type == FileType.DOCUMENT
assert filled_event.submitted_data["attachment"].value.transfer_method == FileTransferMethod.REMOTE_URL
assert isinstance(filled_event.submitted_data["attachments"], ArrayFileSegment)
assert filled_event.submitted_data["attachments"].value_type == SegmentType.ARRAY_FILE
assert filled_event.submitted_data["attachments"].value[0].filename == "a.png"
assert filled_event.submitted_data["attachments"].value[0].type == FileType.IMAGE
def test_human_input_node_emits_timeout_event_before_succeeded():
@@ -0,0 +1,338 @@
import json
from typing import Any
from pydantic import TypeAdapter
from core.app.entities.task_entities import HumanInputRequiredResponse
from core.entities.execution_extra_content import (
HumanInputContent,
HumanInputFormDefinition,
)
from graphon.entities.pause_reason import HumanInputRequired
from graphon.nodes.human_input.entities import (
FormDefinition,
FormInputConfig,
HumanInputNodeData,
)
from graphon.nodes.human_input.enums import ButtonStyle, TimeoutUnit, ValueSourceType
def _legacy_form_input_payloads() -> list[dict[str, Any]]:
return [
{
"type": "paragraph",
"output_variable_name": "name",
"default": {
"type": "constant",
"selector": [],
"value": "Alice",
},
},
{
"type": "select",
"output_variable_name": "decision",
"option_source": {
"type": "constant",
"selector": [],
"value": ["approve", "reject"],
},
},
{
"type": "file",
"output_variable_name": "attachment",
"allowed_file_types": ["document"],
"allowed_file_extensions": [],
"allowed_file_upload_methods": ["remote_url"],
},
{
"type": "file-list",
"output_variable_name": "attachments",
"allowed_file_types": ["document"],
"allowed_file_extensions": [],
"allowed_file_upload_methods": ["remote_url"],
"number_limits": 3,
},
{
"type": "paragraph",
"output_variable_name": "summary",
"default": None,
},
]
def _legacy_user_action_payloads() -> list[dict[str, Any]]:
return [
{
"id": "approve",
"title": "Approve",
"button_style": "primary",
},
{
"id": "reject",
"title": "Reject",
"button_style": "default",
},
]
def _validate_legacy_json(model_class: type, payload: dict[str, Any]) -> Any:
adapter = TypeAdapter(model_class)
return adapter.validate_json(json.dumps(payload))
def test_form_input_accepts_current_serialized_payload() -> None:
payload = {
"type": "paragraph",
"output_variable_name": "name",
"default": {
"type": "constant",
"selector": [],
"value": "Alice",
},
}
restored = _validate_legacy_json(FormInputConfig, payload)
assert restored.default is not None
assert restored.default.type == ValueSourceType.CONSTANT
def test_human_input_node_data_accepts_current_serialized_payload() -> None:
payload = {
"type": "human-input",
"title": "Human Input",
"form_content": "Hello {{#$output.name#}}",
"inputs": _legacy_form_input_payloads(),
"user_actions": _legacy_user_action_payloads(),
"timeout": 2,
"timeout_unit": "day",
}
restored = _validate_legacy_json(HumanInputNodeData, payload)
assert restored.inputs[0].output_variable_name == "name"
assert restored.timeout_unit == TimeoutUnit.DAY
def test_form_definition_accepts_current_serialized_payload() -> None:
payload = {
"form_content": "Please confirm",
"inputs": _legacy_form_input_payloads(),
"user_actions": _legacy_user_action_payloads(),
"rendered_content": "Please confirm",
"expiration_time": "2024-01-01T00:00:00Z",
"default_values": {"name": "Alice"},
"node_title": "Human Input",
"display_in_ui": True,
}
restored = _validate_legacy_json(FormDefinition, payload)
assert restored.inputs[2].output_variable_name == "attachment"
assert restored.user_actions[0].id == "approve"
assert restored.user_actions[0].button_style == ButtonStyle.PRIMARY
def test_human_input_required_pause_reason_accepts_current_serialized_payload() -> None:
payload = {
"TYPE": "human_input_required",
"form_id": "form-1",
"form_content": "Please confirm",
"inputs": _legacy_form_input_payloads(),
"actions": _legacy_user_action_payloads(),
"node_id": "node-1",
"node_title": "Human Input",
"resolved_default_values": {"name": "Alice"},
}
restored = _validate_legacy_json(HumanInputRequired, payload)
assert restored.inputs[1].output_variable_name == "decision"
assert restored.actions[0].id == "approve"
assert restored.TYPE == "human_input_required"
def test_human_input_form_definition_accepts_current_serialized_payload() -> None:
payload = {
"form_id": "form-1",
"node_id": "node-1",
"node_title": "Human Input",
"form_content": "Please confirm",
"inputs": _legacy_form_input_payloads(),
"actions": _legacy_user_action_payloads(),
"display_in_ui": True,
"form_token": "token-1",
"resolved_default_values": {"name": "Alice"},
"expiration_time": 1700000000,
}
restored = _validate_legacy_json(HumanInputFormDefinition, payload)
assert restored.inputs[3].output_variable_name == "attachments"
assert restored.actions[0].id == "approve"
def test_human_input_content_accepts_current_serialized_payload() -> None:
payload = {
"workflow_run_id": "run-1",
"submitted": True,
"form_definition": {
"form_id": "form-1",
"node_id": "node-1",
"node_title": "Human Input",
"form_content": "Please confirm",
"inputs": _legacy_form_input_payloads(),
"actions": _legacy_user_action_payloads(),
"display_in_ui": True,
"form_token": "token-1",
"resolved_default_values": {"name": "Alice"},
"expiration_time": 1700000000,
},
"form_submission_data": {
"node_id": "node-1",
"node_title": "Human Input",
"rendered_content": "Please confirm",
"action_id": "approve",
"action_text": "Approve",
},
"type": "human_input",
}
restored = _validate_legacy_json(HumanInputContent, payload)
assert restored.form_definition is not None
assert restored.form_definition.inputs[0].output_variable_name == "name"
def test_human_input_content_accepts_current_serialized_payload_with_form_data() -> None:
payload = {
"workflow_run_id": "run-1",
"submitted": True,
"form_definition": {
"form_id": "form-1",
"node_id": "node-1",
"node_title": "Human Input",
"form_content": "Please confirm",
"inputs": [
{
"type": "select",
"output_variable_name": "decision",
"option_source": {"type": "constant", "selector": [], "value": ["approve", "reject"]},
},
{
"type": "file",
"output_variable_name": "attachment",
"allowed_file_types": ["document"],
"allowed_file_extensions": [],
"allowed_file_upload_methods": ["remote_url"],
},
{
"type": "file-list",
"output_variable_name": "attachments",
"allowed_file_types": ["document"],
"allowed_file_extensions": [],
"allowed_file_upload_methods": ["remote_url"],
"number_limits": 3,
},
],
"actions": _legacy_user_action_payloads(),
"display_in_ui": True,
"form_token": "token-1",
"resolved_default_values": {"decision": "approve"},
"expiration_time": 1700000000,
},
"form_submission_data": {
"node_id": "node-1",
"node_title": "Human Input",
"rendered_content": "Please confirm",
"action_id": "approve",
"action_text": "Approve",
"submitted_data": {
"decision": "approve",
"attachment": {
"type": "document",
"transfer_method": "remote_url",
"remote_url": "https://example.com/file.txt",
"filename": "file.txt",
"extension": ".txt",
"mime_type": "text/plain",
},
"attachments": [
{
"type": "document",
"transfer_method": "remote_url",
"remote_url": "https://example.com/first.txt",
"filename": "first.txt",
"extension": ".txt",
"mime_type": "text/plain",
}
],
},
},
"type": "human_input",
}
restored = HumanInputContent.model_validate_json(json.dumps(payload))
assert restored.form_submission_data is not None
assert restored.form_submission_data.submitted_data == payload["form_submission_data"]["submitted_data"]
def test_human_input_content_accepts_legacy_serialized_payload_with_form_data() -> None:
payload = {
"workflow_run_id": "run-1",
"submitted": True,
"form_definition": {
"form_id": "form-1",
"node_id": "node-1",
"node_title": "Human Input",
"form_content": "Please confirm",
"inputs": _legacy_form_input_payloads(),
"actions": _legacy_user_action_payloads(),
"display_in_ui": True,
"form_token": "token-1",
"resolved_default_values": {"decision": "approve"},
"expiration_time": 1700000000,
},
"form_submission_data": {
"node_id": "node-1",
"node_title": "Human Input",
"rendered_content": "Please confirm",
"action_id": "approve",
"action_text": "Approve",
"form_data": {
"decision": "approve",
"attachment": {
"type": "document",
"transfer_method": "remote_url",
"remote_url": "https://example.com/file.txt",
"filename": "file.txt",
"extension": ".txt",
"mime_type": "text/plain",
},
},
},
"type": "human_input",
}
restored = HumanInputContent.model_validate_json(json.dumps(payload))
assert restored.form_submission_data is not None
assert restored.form_submission_data.submitted_data is None
def test_human_input_required_response_accepts_current_serialized_payload() -> None:
payload = {
"event": "human_input_required",
"task_id": "task-1",
"workflow_run_id": "run-1",
"data": {
"form_id": "form-1",
"node_id": "node-1",
"node_title": "Human Input",
"form_content": "Please confirm",
"inputs": _legacy_form_input_payloads(),
"actions": _legacy_user_action_payloads(),
"display_in_ui": True,
"form_token": "token-1",
"resolved_default_values": {"name": "Alice"},
"expiration_time": 1700000000,
},
}
restored = _validate_legacy_json(HumanInputRequiredResponse, payload)
assert restored.data.inputs[1].output_variable_name == "decision"
assert restored.data.actions[0].id == "approve"
assert restored.event == "human_input_required"
@@ -1,8 +1,14 @@
import pytest
from core.workflow.human_input_policy import (
HumanInputSurface,
get_preferred_form_token,
is_recipient_type_allowed_for_surface,
resolve_variable_select_input_options,
)
from graphon.nodes.human_input.entities import SelectInputConfig, StringListSource
from graphon.nodes.human_input.enums import ValueSourceType
from graphon.runtime import VariablePool
from models.human_input import RecipientType
@@ -48,3 +54,40 @@ def test_preferred_form_token_uses_shared_priority_order() -> None:
]
assert get_preferred_form_token(recipients) == "backstage-token"
def test_resolve_variable_select_input_options_uses_runtime_values() -> None:
variable_pool = VariablePool()
variable_pool.add(("start", "options"), ["approve", "reject"])
inputs: list[SelectInputConfig] = [
SelectInputConfig(
output_variable_name="decision",
option_source=StringListSource(
type=ValueSourceType.VARIABLE,
selector=["start", "options"],
value=[],
),
)
]
resolved = resolve_variable_select_input_options(inputs, variable_pool=variable_pool)
assert isinstance(resolved[0], SelectInputConfig)
assert resolved[0].option_source.value == ["approve", "reject"]
def test_resolve_variable_select_input_options_keeps_original_when_value_not_string_list() -> None:
variable_pool = VariablePool()
variable_pool.add(("start", "options"), [1, 2, 3])
inputs = [
SelectInputConfig(
output_variable_name="decision",
option_source=StringListSource(
type=ValueSourceType.VARIABLE,
selector=["start", "options"],
value=[],
),
)
]
with pytest.raises(TypeError):
resolve_variable_select_input_options(inputs, variable_pool=variable_pool)
@@ -590,6 +590,7 @@ class TestDifyNodeFactoryCreateNode:
assert kwargs["form_repository"] is form_repository
assert kwargs["file_reference_factory"] is sentinel.file_reference_factory
assert kwargs["runtime"] is factory._human_input_runtime
assert kwargs["file_reference_factory"] is sentinel.file_reference_factory
factory._human_input_runtime.build_form_repository.assert_called_once_with()
elif constructor_name == "ToolNode":
assert kwargs["tool_file_manager"] is sentinel.tool_file_manager
@@ -599,6 +600,50 @@ class TestDifyNodeFactoryCreateNode:
assert kwargs["unstructured_api_config"] is sentinel.unstructured_api_config
assert kwargs["http_client"] is sentinel.remote_file_http_client
def test_human_input_node_receives_runtime_repository_and_file_reference_factory(
self,
monkeypatch: pytest.MonkeyPatch,
factory,
) -> None:
created_node = object()
constructor = _node_constructor(return_value=created_node)
form_repository = sentinel.form_repository
factory._human_input_runtime = MagicMock()
factory._human_input_runtime.build_form_repository.return_value = form_repository
monkeypatch.setattr(
factory,
"_resolve_node_class",
MagicMock(return_value=constructor),
)
result = factory.create_node({"id": "human-node", "data": {"type": BuiltinNodeTypes.HUMAN_INPUT}})
assert result is created_node
kwargs = constructor.call_args.kwargs
assert kwargs["runtime"] is factory._human_input_runtime
assert kwargs["form_repository"] is form_repository
assert kwargs["file_reference_factory"] is sentinel.file_reference_factory
factory._human_input_runtime.build_form_repository.assert_called_once_with()
def test_tool_node_receives_tool_file_manager(self, monkeypatch: pytest.MonkeyPatch, factory) -> None:
created_node = object()
constructor = _node_constructor(return_value=created_node)
factory._bound_tool_file_manager_factory = MagicMock(return_value=sentinel.tool_file_manager)
monkeypatch.setattr(
factory,
"_resolve_node_class",
MagicMock(return_value=constructor),
)
result = factory.create_node({"id": "tool-node", "data": {"type": BuiltinNodeTypes.TOOL}})
assert result is created_node
kwargs = constructor.call_args.kwargs
assert kwargs["tool_file_manager"] is sentinel.tool_file_manager
assert kwargs["runtime"] is sentinel.tool_runtime
assert "tool_file_manager_factory" not in kwargs
factory._bound_tool_file_manager_factory.assert_called_once_with()
def test_build_llm_compatible_node_init_kwargs_preserves_structured_output_switch(self, factory):
node_data = LLMNodeData.model_validate(
{
@@ -29,11 +29,12 @@ from core.workflow.node_runtime import (
build_dify_llm_file_saver,
resolve_dify_run_context,
)
from graphon.file import FileTransferMethod, FileType
from graphon.file import File, FileTransferMethod, FileType
from graphon.model_runtime.entities.common_entities import I18nObject
from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelType
from graphon.nodes.human_input.entities import HumanInputNodeData
from graphon.nodes.human_input.entities import FileInputConfig, FileListInputConfig, HumanInputNodeData
from graphon.nodes.tool.entities import ToolNodeData, ToolProviderType
from graphon.variables.segments import ArrayFileSegment, FileSegment
from tests.workflow_test_utils import build_test_run_context
@@ -621,6 +622,70 @@ def test_dify_human_input_runtime_preserves_webapp_delivery_for_web_invocations(
assert params.delivery_methods[1].config.recipients.include_bound_group is True
def test_dify_human_input_runtime_restore_submitted_data_rehydrates_files() -> None:
runtime = DifyHumanInputNodeRuntime(_build_run_context())
file_value = File(
file_id="file-1",
file_type=FileType.DOCUMENT,
transfer_method=FileTransferMethod.LOCAL_FILE,
related_id="upload-1",
filename="resume.pdf",
extension=".pdf",
mime_type="application/pdf",
size=128,
)
file_list_value = [
File(
file_id="file-2",
file_type=FileType.DOCUMENT,
transfer_method=FileTransferMethod.LOCAL_FILE,
related_id="upload-2",
filename="first.pdf",
extension=".pdf",
mime_type="application/pdf",
size=64,
),
File(
file_id="file-3",
file_type=FileType.DOCUMENT,
transfer_method=FileTransferMethod.REMOTE_URL,
remote_url="https://example.com/second.pdf",
filename="second.pdf",
extension=".pdf",
mime_type="application/pdf",
size=96,
),
]
runtime._file_reference_factory.build_from_mapping = MagicMock(side_effect=[file_value, *file_list_value]) # type: ignore[method-assign]
node_data = HumanInputNodeData(
title="Human Input",
inputs=[
FileInputConfig(output_variable_name="attachment"),
FileListInputConfig(output_variable_name="attachments", number_limits=2),
],
)
restored = runtime.restore_submitted_data(
node_data=node_data,
submitted_data={
"attachment": {"upload_file_id": "upload-1", "type": "document", "transfer_method": "local_file"},
"attachments": [
{"upload_file_id": "upload-2", "type": "document", "transfer_method": "local_file"},
{
"url": "https://example.com/second.pdf",
"type": "document",
"transfer_method": "remote_url",
},
],
},
)
assert restored["attachment"] is file_value
assert restored["attachments"] == file_list_value
assert isinstance(FileSegment(value=restored["attachment"]), FileSegment)
assert isinstance(ArrayFileSegment(value=restored["attachments"]), ArrayFileSegment)
def test_build_dify_llm_file_saver_wires_runtime_adapters(monkeypatch: pytest.MonkeyPatch) -> None:
file_saver_cls = MagicMock(return_value=sentinel.file_saver)
monkeypatch.setattr("graphon.nodes.llm.file_saver.FileSaverImpl", file_saver_cls)
@@ -421,3 +421,29 @@ def test_disallowed_extensions(mock_upload_file):
with pytest.raises(ValueError, match="File validation failed"):
build_from_mapping(mapping=mapping, tenant_id=TEST_TENANT_ID, config=restricted_config)
def test_custom_file_type_uses_extension_validation_under_strict_mode(mock_upload_file):
"""Custom form uploads are classified by the configured extension list."""
mock_upload_file.return_value.extension = "txt"
mock_upload_file.return_value.name = "notes.txt"
mock_upload_file.return_value.mime_type = "text/plain"
custom_config = FileUploadConfig(
allowed_file_types=[FileType.CUSTOM],
allowed_file_extensions=[".txt"],
)
mapping = {
"transfer_method": "local_file",
"upload_file_id": TEST_UPLOAD_FILE_ID,
"type": "custom",
}
file = build_from_mapping(
mapping=mapping,
tenant_id=TEST_TENANT_ID,
config=custom_config,
strict_type_validation=True,
)
assert file.type == FileType.CUSTOM
@@ -4,7 +4,7 @@ from dataclasses import dataclass, field
from datetime import datetime, timedelta
from typing import Any
from graphon.nodes.human_input.entities import ParagraphInputConfig
from graphon.nodes.human_input.entities import FormInputConfig
from graphon.nodes.human_input.enums import TimeoutUnit
from libs.datetime_utils import naive_utc_now
@@ -45,7 +45,7 @@ class HumanInputForm:
tenant_id: str
app_id: str | None
form_content: str
inputs: list[ParagraphInputConfig]
inputs: list[FormInputConfig]
user_actions: list[dict[str, Any]]
timeout: int
timeout_unit: TimeoutUnit
@@ -88,7 +88,7 @@ class HumanInputForm:
def to_response_dict(self, *, include_site_info: bool) -> dict[str, Any]:
inputs_response = [
{
"type": form_input.type.name.lower().replace("_", "-"),
"type": form_input.type.value,
"output_variable_name": form_input.output_variable_name,
}
for form_input in self.inputs
@@ -11,7 +11,6 @@ from graphon.nodes.human_input.entities import (
UserActionConfig,
)
from graphon.nodes.human_input.enums import (
FormInputType,
TimeoutUnit,
)
from libs.datetime_utils import naive_utc_now
@@ -50,7 +49,7 @@ class TestFormService:
"tenant_id": "tenant-abc",
"app_id": "app-def",
"form_content": "# Test Form\n\nInput: {{#$output.input#}}",
"inputs": [ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="input", default=None)],
"inputs": [ParagraphInputConfig(output_variable_name="input")],
"user_actions": [UserActionConfig(id="submit", title="Submit")],
"timeout": 1,
"timeout_unit": TimeoutUnit.HOUR,
@@ -304,9 +303,7 @@ class TestFormValidation:
"tenant_id": "tenant-abc",
"app_id": "app-def",
"form_content": "Test form",
"inputs": [
ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="required_input", default=None)
],
"inputs": [ParagraphInputConfig(output_variable_name="required_input")],
"user_actions": [UserActionConfig(id="submit", title="Submit")],
"timeout": 1,
"timeout_unit": TimeoutUnit.HOUR,
@@ -11,7 +11,6 @@ from graphon.nodes.human_input.entities import (
UserActionConfig,
)
from graphon.nodes.human_input.enums import (
FormInputType,
TimeoutUnit,
)
from libs.datetime_utils import naive_utc_now
@@ -32,7 +31,7 @@ class TestHumanInputForm:
"tenant_id": "tenant-abc",
"app_id": "app-def",
"form_content": "# Test Form\n\nInput: {{#$output.input#}}",
"inputs": [ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="input", default=None)],
"inputs": [ParagraphInputConfig(output_variable_name="input")],
"user_actions": [UserActionConfig(id="submit", title="Submit")],
"timeout": 2,
"timeout_unit": TimeoutUnit.HOUR,
@@ -0,0 +1,101 @@
from __future__ import annotations
import json
from datetime import timedelta
from typing import cast
from sqlalchemy.orm import Session, sessionmaker
from graphon.nodes.human_input.entities import FormDefinition, UserActionConfig
from graphon.nodes.human_input.enums import HumanInputFormStatus
from libs.datetime_utils import naive_utc_now
from models.execution_extra_content import HumanInputContent as HumanInputContentModel
from models.human_input import HumanInputForm
from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository
def test_map_human_input_content_populates_submission_data_from_stored_form_submission() -> None:
expiration_time = naive_utc_now() + timedelta(days=1)
stored_submission_data = {"decision": "approve", "comment": "Looks good"}
form_definition = FormDefinition(
form_content="content",
inputs=[],
user_actions=[UserActionConfig(id="approve", title="Approve")],
rendered_content="Rendered Approve",
expiration_time=expiration_time,
node_title="Approval",
display_in_ui=True,
)
form = HumanInputForm(
tenant_id="tenant-1",
app_id="app-1",
workflow_run_id="workflow-run-1",
node_id="node-1",
form_definition=form_definition.model_dump_json(),
rendered_content="Rendered Approve",
expiration_time=expiration_time,
selected_action_id="approve",
submitted_data=json.dumps(stored_submission_data),
submitted_at=naive_utc_now(),
status=HumanInputFormStatus.SUBMITTED,
)
form.id = "form-1"
model = HumanInputContentModel.new(
workflow_run_id="workflow-run-1",
form_id=form.id,
message_id="message-1",
)
model.id = "content-1"
model.form = form
repository = SQLAlchemyExecutionExtraContentRepository(cast(sessionmaker[Session], object()))
content = repository._map_human_input_content(model, {})
assert content is not None
assert content.form_submission_data is not None
assert content.form_submission_data.submitted_data == stored_submission_data
def test_map_human_input_content_keeps_waiting_form_without_selected_action() -> None:
expiration_time = naive_utc_now() + timedelta(days=1)
form_definition = FormDefinition(
form_content="content",
inputs=[],
user_actions=[UserActionConfig(id="approve", title="Approve")],
rendered_content="Rendered Approval",
expiration_time=expiration_time,
node_title="Approval",
display_in_ui=True,
default_values={"decision": "approve"},
)
form = HumanInputForm(
tenant_id="tenant-1",
app_id="app-1",
workflow_run_id="workflow-run-1",
node_id="node-1",
form_definition=form_definition.model_dump_json(),
rendered_content="Rendered Approval",
expiration_time=expiration_time,
status=HumanInputFormStatus.WAITING,
)
form.id = "form-1"
model = HumanInputContentModel.new(
workflow_run_id="workflow-run-1",
form_id=form.id,
message_id="message-1",
)
model.id = "content-1"
model.form = form
repository = SQLAlchemyExecutionExtraContentRepository(cast(sessionmaker[Session], object()))
content = repository._map_human_input_content(model, {})
assert content is not None
assert content.submitted is False
assert content.form_submission_data is None
assert content.form_definition is not None
assert content.form_definition.form_id == "form-1"
assert content.form_definition.node_id == "node-1"
assert content.form_definition.node_title == "Approval"
assert content.form_definition.form_content == "Rendered Approval"
assert content.form_definition.resolved_default_values == {"decision": "approve"}
@@ -0,0 +1,304 @@
from __future__ import annotations
from datetime import timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from sqlalchemy import create_engine, select
from sqlalchemy.orm import sessionmaker
import models.account as account_module
import services.human_input_file_upload_service as service_module
from graphon.enums import WorkflowExecutionStatus
from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
from libs.datetime_utils import naive_utc_now
from models.account import Account, Tenant, TenantAccountJoin
from models.base import Base
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
from models.human_input import (
HumanInputForm,
HumanInputFormRecipient,
HumanInputFormUploadFile,
HumanInputFormUploadToken,
)
from models.model import App, AppMode, EndUser
from models.workflow import WorkflowRun, WorkflowType
from services.human_input_file_upload_service import HITL_UPLOAD_TOKEN_PREFIX, HumanInputFileUploadService
from services.human_input_service import FormSubmittedError
@pytest.fixture
def session_maker(monkeypatch: pytest.MonkeyPatch):
engine = create_engine("sqlite:///:memory:")
monkeypatch.setattr(account_module, "db", SimpleNamespace(engine=engine))
Base.metadata.create_all(
engine,
tables=[
Tenant.__table__,
Account.__table__,
TenantAccountJoin.__table__,
App.__table__,
EndUser.__table__,
WorkflowRun.__table__,
HumanInputForm.__table__,
HumanInputFormRecipient.__table__,
HumanInputFormUploadToken.__table__,
HumanInputFormUploadFile.__table__,
],
)
try:
yield sessionmaker(bind=engine, expire_on_commit=False)
finally:
Base.metadata.drop_all(
engine,
tables=[
HumanInputFormUploadFile.__table__,
HumanInputFormUploadToken.__table__,
HumanInputFormRecipient.__table__,
HumanInputForm.__table__,
WorkflowRun.__table__,
EndUser.__table__,
App.__table__,
TenantAccountJoin.__table__,
Account.__table__,
Tenant.__table__,
],
)
engine.dispose()
def _create_waiting_form(
session_maker,
*,
created_by_role: CreatorUserRole = CreatorUserRole.ACCOUNT,
form_kind: HumanInputFormKind = HumanInputFormKind.RUNTIME,
) -> tuple[str, str, str]:
form_id = "00000000-0000-0000-0000-000000000001"
recipient_id = "00000000-0000-0000-0000-000000000002"
workflow_run_id = None
if form_kind == HumanInputFormKind.RUNTIME:
workflow_run_id = "00000000-0000-0000-0000-000000000012"
tenant_id = "00000000-0000-0000-0000-000000000010"
app_id = "00000000-0000-0000-0000-000000000011"
now = naive_utc_now()
created_by = (
"00000000-0000-0000-0000-000000000020"
if created_by_role == CreatorUserRole.ACCOUNT
else "00000000-0000-0000-0000-000000000021"
)
with session_maker.begin() as session:
tenant = Tenant(name="tenant-1")
tenant.id = tenant_id
session.add(tenant)
if created_by_role == CreatorUserRole.ACCOUNT:
account = Account(name="owner", email="owner@example.com")
account.id = created_by
session.add(account)
session.add(
TenantAccountJoin(
tenant_id=tenant_id,
account_id=created_by,
current=True,
)
)
app_creator = created_by
else:
end_user = EndUser(
tenant_id=tenant_id,
app_id=app_id,
type="web_app",
is_anonymous=False,
session_id="session-1",
external_user_id="external-1",
)
end_user.id = created_by
session.add(end_user)
app_creator = "00000000-0000-0000-0000-000000000020"
account = Account(name="owner", email="owner@example.com")
account.id = app_creator
session.add(account)
session.add(
TenantAccountJoin(
tenant_id=tenant_id,
account_id=app_creator,
current=True,
)
)
app = App(
tenant_id=tenant_id,
name="app-1",
description="",
mode=AppMode.WORKFLOW,
icon_type="emoji",
icon="app",
icon_background="#ffffff",
enable_site=True,
enable_api=True,
created_by=app_creator,
updated_by=app_creator,
)
app.id = app_id
session.add(app)
if workflow_run_id is not None:
workflow_run = WorkflowRun(
tenant_id=tenant_id,
app_id=app_id,
workflow_id="00000000-0000-0000-0000-000000000013",
type=WorkflowType.WORKFLOW,
triggered_from=WorkflowRunTriggeredFrom.APP_RUN,
version="1",
graph="{}",
inputs="{}",
status=WorkflowExecutionStatus.RUNNING,
created_by_role=created_by_role,
created_by=created_by,
created_at=now,
)
workflow_run.id = workflow_run_id
session.add(workflow_run)
session.add(
HumanInputForm(
id=form_id,
tenant_id=tenant_id,
app_id=app_id,
workflow_run_id=workflow_run_id,
form_kind=form_kind,
node_id="node-1",
form_definition="{}",
rendered_content="content",
expiration_time=now + timedelta(hours=1),
created_at=now,
)
)
session.add(
HumanInputFormRecipient(
id=recipient_id,
form_id=form_id,
delivery_id="00000000-0000-0000-0000-000000000003",
recipient_type="standalone_web_app",
recipient_payload='{"TYPE": "standalone_web_app"}',
access_token="form-token-1",
)
)
return form_id, recipient_id, created_by
def _create_service(
session_maker,
workflow_run_repository: MagicMock | None = None,
) -> HumanInputFileUploadService:
return HumanInputFileUploadService(
session_maker,
workflow_run_repository=workflow_run_repository or MagicMock(),
)
def test_issue_upload_token_persists_token_without_technical_end_user(
monkeypatch: pytest.MonkeyPatch,
session_maker,
) -> None:
form_id, recipient_id, _created_by = _create_waiting_form(session_maker)
monkeypatch.setattr(service_module.secrets, "token_urlsafe", lambda _bytes: "random-value")
token = _create_service(session_maker).issue_upload_token("form-token-1")
assert token.upload_token == f"{HITL_UPLOAD_TOKEN_PREFIX}random-value"
with session_maker() as session:
token_model = session.scalar(select(HumanInputFormUploadToken))
assert token_model is not None
assert token_model.form_id == form_id
assert token_model.recipient_id == recipient_id
assert token_model.token == token.upload_token
assert session.scalar(select(EndUser).where(EndUser.type == "human-input")) is None
def test_validate_upload_token_returns_account_owner_and_record_file_link(session_maker) -> None:
form_id, recipient_id, created_by = _create_waiting_form(session_maker, created_by_role=CreatorUserRole.ACCOUNT)
token = _create_service(session_maker).issue_upload_token("form-token-1")
workflow_run_repository = MagicMock()
workflow_run_repository.get_workflow_run_by_id.return_value = SimpleNamespace(
tenant_id="00000000-0000-0000-0000-000000000010",
app_id="00000000-0000-0000-0000-000000000011",
created_by_role=CreatorUserRole.ACCOUNT,
created_by=created_by,
)
context = HumanInputFileUploadService(
session_maker,
workflow_run_repository=workflow_run_repository,
).validate_upload_token(token.upload_token)
assert context.form_id == form_id
assert context.recipient_id == recipient_id
assert isinstance(context.owner, Account)
assert context.owner.id == created_by
assert context.owner.current_tenant_id == "00000000-0000-0000-0000-000000000010"
workflow_run_repository.get_workflow_run_by_id.assert_called_once_with(
tenant_id="00000000-0000-0000-0000-000000000010",
app_id="00000000-0000-0000-0000-000000000011",
run_id="00000000-0000-0000-0000-000000000012",
)
_create_service(session_maker).record_upload_file(
context=context,
file_id="00000000-0000-0000-0000-000000000099",
)
with session_maker() as session:
link = session.scalar(select(HumanInputFormUploadFile))
assert link is not None
assert link.tenant_id == context.tenant_id
assert link.app_id == context.app_id
assert link.form_id == form_id
assert link.upload_token_id == context.upload_token_id
def test_validate_upload_token_returns_end_user_owner(session_maker) -> None:
form_id, recipient_id, created_by = _create_waiting_form(session_maker, created_by_role=CreatorUserRole.END_USER)
token = _create_service(session_maker).issue_upload_token("form-token-1")
workflow_run_repository = MagicMock()
workflow_run_repository.get_workflow_run_by_id.return_value = SimpleNamespace(
tenant_id="00000000-0000-0000-0000-000000000010",
app_id="00000000-0000-0000-0000-000000000011",
created_by_role=CreatorUserRole.END_USER,
created_by=created_by,
)
context = HumanInputFileUploadService(
session_maker,
workflow_run_repository=workflow_run_repository,
).validate_upload_token(token.upload_token)
assert context.form_id == form_id
assert context.recipient_id == recipient_id
assert isinstance(context.owner, EndUser)
assert context.owner.id == created_by
def test_validate_upload_token_allows_delivery_test_form(session_maker) -> None:
form_id, recipient_id, _created_by = _create_waiting_form(
session_maker,
form_kind=HumanInputFormKind.DELIVERY_TEST,
)
token = _create_service(session_maker).issue_upload_token("form-token-1")
context = _create_service(session_maker).validate_upload_token(token.upload_token)
assert context.form_id == form_id
assert context.recipient_id == recipient_id
assert isinstance(context.owner, Account)
assert context.owner.id == "00000000-0000-0000-0000-000000000020"
assert context.owner.current_tenant_id == "00000000-0000-0000-0000-000000000010"
def test_validate_upload_token_rejects_submitted_form(session_maker) -> None:
form_id, _recipient_id, _created_by = _create_waiting_form(session_maker)
token = _create_service(session_maker).issue_upload_token("form-token-1")
with session_maker.begin() as session:
form = session.get(HumanInputForm, form_id)
assert form is not None
form.status = HumanInputFormStatus.SUBMITTED
form.submitted_at = naive_utc_now()
with pytest.raises(FormSubmittedError):
_create_service(session_maker).validate_upload_token(token.upload_token)
@@ -6,18 +6,28 @@ import pytest
from pytest_mock import MockerFixture
import services.human_input_service as human_input_service_module
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.repositories.human_input_repository import (
HumanInputFormRecord,
HumanInputFormSubmissionRepository,
)
from graphon.file import File, FileTransferMethod, FileType
from graphon.nodes.human_input.entities import (
FileInputConfig,
FileListInputConfig,
FormDefinition,
ParagraphInputConfig,
SelectInputConfig,
StringListSource,
UserActionConfig,
)
from graphon.nodes.human_input.enums import FormInputType, HumanInputFormKind, HumanInputFormStatus
from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType
from graphon.runtime import GraphRuntimeState, VariablePool
from libs.datetime_utils import naive_utc_now
from models.human_input import RecipientType
from models.model import AppMode
from services.human_input_service import (
Form,
FormExpiredError,
@@ -178,6 +188,70 @@ def test_get_form_definition_by_token_for_console_uses_repository(sample_form_re
assert form.get_definition() == console_record.definition
def _build_resumption_context_state(*, options: list[str], workflow_run_id: str) -> bytes:
app_config = WorkflowUIBasedAppConfig(
tenant_id="tenant-id",
app_id="app-id",
app_mode=AppMode.WORKFLOW,
workflow_id="workflow-id",
)
generate_entity = WorkflowAppGenerateEntity(
task_id="task-id",
app_config=app_config,
inputs={},
files=[],
user_id="user-id",
stream=True,
invoke_from=InvokeFrom.EXPLORE,
call_depth=0,
workflow_execution_id=workflow_run_id,
)
runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0)
runtime_state.variable_pool.add(("start", "options"), options)
context = WorkflowResumptionContext(
generate_entity=_WorkflowGenerateEntityWrapper(entity=generate_entity),
serialized_graph_runtime_state=runtime_state.dumps(),
)
return context.dumps().encode()
def test_resolve_form_inputs_uses_runtime_select_options(sample_form_record, mock_session_factory, mocker):
session_factory, _ = mock_session_factory
configured_input = SelectInputConfig(
output_variable_name="decision",
option_source=StringListSource(
type=ValueSourceType.VARIABLE,
selector=["start", "options"],
value=["configured"],
),
)
record = dataclasses.replace(
sample_form_record,
definition=sample_form_record.definition.model_copy(update={"inputs": [configured_input]}),
)
pause = MagicMock()
pause.resumed_at = None
pause.get_state.return_value = _build_resumption_context_state(
options=["approve", "reject"],
workflow_run_id=record.workflow_run_id or "",
)
workflow_run_repo = MagicMock()
workflow_run_repo.get_workflow_pause.return_value = pause
mocker.patch(
"services.human_input_service.DifyAPIRepositoryFactory.create_api_workflow_run_repository",
return_value=workflow_run_repo,
)
service = HumanInputService(session_factory)
resolved_inputs = service.resolve_form_inputs(Form(record))
assert len(resolved_inputs) == 1
resolved_input = resolved_inputs[0]
assert isinstance(resolved_input, SelectInputConfig)
assert resolved_input.option_source.value == ["approve", "reject"]
workflow_run_repo.get_workflow_pause.assert_called_once_with(record.workflow_run_id)
def test_submit_form_by_token_calls_repository_and_enqueue(
sample_form_record, mock_session_factory, mocker: MockerFixture
):
@@ -280,7 +354,7 @@ def test_submit_form_by_token_missing_inputs(sample_form_record, mock_session_fa
definition_with_input = FormDefinition(
form_content="hello",
inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="content")],
inputs=[ParagraphInputConfig(output_variable_name="content")],
user_actions=sample_form_record.definition.user_actions,
rendered_content="<p>hello</p>",
expiration_time=sample_form_record.expiration_time,
@@ -301,6 +375,123 @@ def test_submit_form_by_token_missing_inputs(sample_form_record, mock_session_fa
repo.mark_submitted.assert_not_called()
def test_validate_human_input_submission_accepts_select_file_and_file_list(mock_session_factory):
session_factory, _ = mock_session_factory
service = HumanInputService(session_factory)
definition = FormDefinition.model_validate(
{
"form_content": "Pick one and upload files",
"inputs": [
{
"type": "select",
"output_variable_name": "decision",
"option_source": {
"type": "constant",
"value": ["approve", "reject"],
},
},
{
"type": "file",
"output_variable_name": "attachment",
"allowed_file_types": ["document"],
"allowed_file_upload_methods": ["remote_url"],
},
{
"type": "file-list",
"output_variable_name": "attachments",
"allowed_file_types": ["document"],
"allowed_file_upload_methods": ["remote_url"],
"number_limits": 3,
},
],
"user_actions": [{"id": "submit", "title": "Submit"}],
"rendered_content": "<p>Pick one and upload files</p>",
"expiration_time": naive_utc_now() + timedelta(hours=1),
}
)
@pytest.mark.parametrize(
("input_definition", "submitted_value", "expected_message"),
[
(
{
"type": "select",
"output_variable_name": "decision",
"option_source": {
"type": "constant",
"value": ["approve", "reject"],
},
},
"unknown",
"decision",
),
(
{
"type": "file",
"output_variable_name": "attachment",
"allowed_file_types": ["document"],
"allowed_file_upload_methods": ["remote_url"],
},
"not-a-file",
"attachment",
),
(
{
"type": "file-list",
"output_variable_name": "attachments",
"allowed_file_types": ["document"],
"allowed_file_upload_methods": ["remote_url"],
"number_limits": 2,
},
[
{
"type": "document",
"transfer_method": "remote_url",
"remote_url": "https://example.com/ok.txt",
"filename": "ok.txt",
"extension": ".txt",
"mime_type": "text/plain",
},
"not-a-file",
],
"attachments",
),
],
)
def test_validate_human_input_submission_rejects_invalid_select_and_file_payloads(
sample_form_record,
mock_session_factory,
input_definition,
submitted_value,
expected_message,
):
session_factory, _ = mock_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
definition = FormDefinition.model_validate(
{
"form_content": "Validate form data",
"inputs": [input_definition],
"user_actions": [{"id": "submit", "title": "Submit"}],
"rendered_content": "<p>Validate form data</p>",
"expiration_time": naive_utc_now() + timedelta(hours=1),
}
)
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
service = HumanInputService(session_factory, form_repository=repo)
with pytest.raises(InvalidFormDataError) as exc_info:
service.submit_form_by_token(
recipient_type=RecipientType.STANDALONE_WEB_APP,
form_token="token",
selected_action_id="submit",
form_data={input_definition["output_variable_name"]: submitted_value},
)
assert expected_message in str(exc_info.value)
repo.mark_submitted.assert_not_called()
def test_form_properties(sample_form_record):
form = Form(sample_form_record)
assert form.id == "form-id"
@@ -468,3 +659,203 @@ def test_is_globally_expired_zero_timeout(monkeypatch, sample_form_record, mock_
monkeypatch.setattr(human_input_service_module.dify_config, "HUMAN_INPUT_GLOBAL_TIMEOUT_SECONDS", 0)
assert service._is_globally_expired(Form(sample_form_record)) is False
def test_submit_form_by_token_normalizes_select_and_files(sample_form_record, mock_session_factory, mocker) -> None:
session_factory, _ = mock_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
definition = FormDefinition(
form_content="hello",
inputs=[
SelectInputConfig(
output_variable_name="decision",
option_source=StringListSource(type=ValueSourceType.CONSTANT, value=["approve", "reject"]),
),
FileInputConfig(output_variable_name="attachment"),
FileListInputConfig(output_variable_name="attachments", number_limits=3),
],
user_actions=[UserActionConfig(id="submit", title="Submit")],
rendered_content="<p>hello</p>",
expiration_time=sample_form_record.expiration_time,
)
form_with_inputs = dataclasses.replace(sample_form_record, definition=definition)
repo.get_by_token.return_value = form_with_inputs
repo.mark_submitted.return_value = form_with_inputs
service = HumanInputService(session_factory, form_repository=repo)
single_file = File(
file_id="file-1",
file_type=FileType.DOCUMENT,
transfer_method=FileTransferMethod.LOCAL_FILE,
related_id="upload-1",
filename="resume.pdf",
extension=".pdf",
mime_type="application/pdf",
size=128,
)
list_files = [
File(
file_id="file-2",
file_type=FileType.DOCUMENT,
transfer_method=FileTransferMethod.LOCAL_FILE,
related_id="upload-2",
filename="a.pdf",
extension=".pdf",
mime_type="application/pdf",
size=64,
),
File(
file_id="file-3",
file_type=FileType.DOCUMENT,
transfer_method=FileTransferMethod.REMOTE_URL,
remote_url="https://example.com/b.pdf",
filename="b.pdf",
extension=".pdf",
mime_type="application/pdf",
size=96,
),
]
mocker.patch("services.human_input_service.build_from_mapping", return_value=single_file)
mocker.patch("services.human_input_service.build_from_mappings", return_value=list_files)
enqueue_spy = mocker.patch.object(service, "enqueue_resume")
service.submit_form_by_token(
recipient_type=RecipientType.STANDALONE_WEB_APP,
form_token="token",
selected_action_id="submit",
form_data={
"decision": "approve",
"attachment": {"transfer_method": "local_file", "upload_file_id": "upload-1", "type": "document"},
"attachments": [
{"transfer_method": "local_file", "upload_file_id": "upload-2", "type": "document"},
{"transfer_method": "remote_url", "url": "https://example.com/b.pdf", "type": "document"},
],
},
)
submitted_data = repo.mark_submitted.call_args.kwargs["form_data"]
assert submitted_data["decision"] == "approve"
assert submitted_data["attachment"]["filename"] == "resume.pdf"
assert submitted_data["attachment"]["transfer_method"] == "local_file"
assert submitted_data["attachments"][0]["filename"] == "a.pdf"
assert submitted_data["attachments"][1]["filename"] == "b.pdf"
enqueue_spy.assert_called_once_with(sample_form_record.workflow_run_id)
def test_submit_form_by_token_invalid_select_value(sample_form_record, mock_session_factory) -> None:
session_factory, _ = mock_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
definition = FormDefinition(
form_content="hello",
inputs=[
SelectInputConfig(
output_variable_name="decision",
option_source=StringListSource(type=ValueSourceType.CONSTANT, value=["approve", "reject"]),
)
],
user_actions=[UserActionConfig(id="submit", title="Submit")],
rendered_content="<p>hello</p>",
expiration_time=sample_form_record.expiration_time,
)
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
service = HumanInputService(session_factory, form_repository=repo)
with pytest.raises(InvalidFormDataError, match="Invalid value for select input 'decision'"):
service.submit_form_by_token(
recipient_type=RecipientType.STANDALONE_WEB_APP,
form_token="token",
selected_action_id="submit",
form_data={"decision": "hold"},
)
def test_submit_form_by_token_invalid_file_list_item(sample_form_record, mock_session_factory) -> None:
session_factory, _ = mock_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
definition = FormDefinition(
form_content="hello",
inputs=[FileListInputConfig(output_variable_name="attachments", number_limits=2)],
user_actions=[UserActionConfig(id="submit", title="Submit")],
rendered_content="<p>hello</p>",
expiration_time=sample_form_record.expiration_time,
)
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
service = HumanInputService(session_factory, form_repository=repo)
with pytest.raises(
InvalidFormDataError,
match="Invalid value for file list input 'attachments'",
):
service.submit_form_by_token(
recipient_type=RecipientType.STANDALONE_WEB_APP,
form_token="token",
selected_action_id="submit",
form_data={"attachments": ["not-a-file"]},
)
def test_submit_form_by_token_rejects_cross_tenant_file(sample_form_record, mock_session_factory, mocker) -> None:
session_factory, _ = mock_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
definition = FormDefinition(
form_content="hello",
inputs=[FileInputConfig(output_variable_name="attachment")],
user_actions=[UserActionConfig(id="submit", title="Submit")],
rendered_content="<p>hello</p>",
expiration_time=sample_form_record.expiration_time,
)
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
service = HumanInputService(session_factory, form_repository=repo)
mocker.patch("services.human_input_service.build_from_mapping", side_effect=ValueError("Invalid upload file"))
with pytest.raises(InvalidFormDataError, match="Invalid value for file input 'attachment'"):
service.submit_form_by_token(
recipient_type=RecipientType.STANDALONE_WEB_APP,
form_token="token",
selected_action_id="submit",
form_data={
"attachment": {
"transfer_method": "local_file",
"upload_file_id": "4e0d1b87-52f2-49f6-b8c6-95cd9c954b3e",
"type": "document",
}
},
)
repo.mark_submitted.assert_not_called()
def test_submit_form_by_token_rejects_cross_tenant_file_list(sample_form_record, mock_session_factory, mocker) -> None:
session_factory, _ = mock_session_factory
repo = MagicMock(spec=HumanInputFormSubmissionRepository)
definition = FormDefinition(
form_content="hello",
inputs=[FileListInputConfig(output_variable_name="attachments", number_limits=2)],
user_actions=[UserActionConfig(id="submit", title="Submit")],
rendered_content="<p>hello</p>",
expiration_time=sample_form_record.expiration_time,
)
repo.get_by_token.return_value = dataclasses.replace(sample_form_record, definition=definition)
service = HumanInputService(session_factory, form_repository=repo)
mocker.patch("services.human_input_service.build_from_mappings", side_effect=ValueError("Invalid upload file"))
with pytest.raises(
InvalidFormDataError,
match="Invalid value for file list input 'attachments'",
):
service.submit_form_by_token(
recipient_type=RecipientType.STANDALONE_WEB_APP,
form_token="token",
selected_action_id="submit",
form_data={
"attachments": [
{
"transfer_method": "local_file",
"upload_file_id": "4e0d1b87-52f2-49f6-b8c6-95cd9c954b3e",
"type": "document",
}
]
},
)
repo.mark_submitted.assert_not_called()
@@ -2682,6 +2682,7 @@ class TestWorkflowServiceHumanInputOperations:
SimpleNamespace(id="submit", title="card_visa_enterprise_001"),
]
mock_node.node_data.outputs_field_names.return_value = ["field1"]
mock_node.node_data.inputs = []
mock_node.render_form_content_before_submission.return_value = "Ticket: {{#$output.field1#}}"
mock_node.render_form_content_with_outputs.return_value = "Ticket: val1"
@@ -2691,7 +2692,10 @@ class TestWorkflowServiceHumanInputOperations:
patch("models.workflow.Workflow.get_node_type_from_node_config", return_value=BuiltinNodeTypes.HUMAN_INPUT),
patch.object(service, "_build_human_input_variable_pool"),
patch("services.workflow_service.HumanInputNode", return_value=mock_node),
patch("services.workflow_service.validate_human_input_submission"),
patch(
"services.workflow_service.HumanInputService.validate_and_normalize_submission",
return_value={"field1": "val1"},
) as mock_validate,
patch("services.workflow_service.Session"),
patch("services.workflow_service.DraftVariableSaver") as mock_saver_cls,
):
@@ -2699,6 +2703,7 @@ class TestWorkflowServiceHumanInputOperations:
app_model=app_model, account=account, node_id="node-1", form_inputs={"field1": "val1"}, action="submit"
)
assert result["__action_id"] == "submit"
mock_validate.assert_called_once()
assert result["__action_value"] == "card_visa_enterprise_001"
assert result["__rendered_content"] == "Ticket: val1"
mock_saver_cls.return_value.save.assert_called_once()
@@ -2714,7 +2719,7 @@ class TestWorkflowServiceHumanInputOperations:
patch.object(service, "_resolve_human_input_delivery_method") as mock_resolve,
patch("services.workflow_service.apply_dify_debug_email_recipient"),
patch.object(service, "_build_human_input_variable_pool"),
patch.object(service, "_build_human_input_node"),
patch.object(service, "_build_human_input_node_for_debugging"),
patch.object(service, "_create_human_input_delivery_test_form", return_value=("form-1", [])),
patch("services.workflow_service.HumanInputDeliveryTestService") as mock_test_srv,
):
@@ -2842,8 +2847,8 @@ class TestWorkflowServiceFreeNodeExecution:
with pytest.raises(Exception, match="unreachable"):
_rebuild_single_file("tenant-1", {}, cast(Any, "invalid_type"))
def test_build_human_input_node(self, service: WorkflowService) -> None:
"""Cover _build_human_input_node (lines 1065-1088)."""
def test_build_human_input_node_for_debugging(self, service: WorkflowService) -> None:
"""Cover _build_human_input_node_for_debugging."""
workflow = MagicMock()
workflow.id = "wf-1"
workflow.tenant_id = "t-1"
@@ -2863,10 +2868,11 @@ class TestWorkflowServiceFreeNodeExecution:
patch("services.workflow_service.build_dify_run_context") as mock_build_dify_run_context,
patch("services.workflow_service.DifyFileReferenceFactory") as mock_file_reference_factory_cls,
patch("services.workflow_service.DifyHumanInputNodeRuntime") as mock_runtime_cls,
patch("services.workflow_service.DifyFileReferenceFactory") as mock_file_reference_factory_cls,
patch("services.workflow_service.HumanInputNode") as mock_node_cls,
):
mock_node_cls.validate_node_data.return_value = sentinel.node_data
node = service._build_human_input_node(
node = service._build_human_input_node_for_debugging(
workflow=workflow, account=account, node_config=node_config, variable_pool=variable_pool
)
assert node == mock_node_cls.return_value
@@ -2878,11 +2884,10 @@ class TestWorkflowServiceFreeNodeExecution:
call_depth=0,
)
mock_runtime_cls.assert_called_once_with(mock_build_dify_run_context.return_value)
mock_file_reference_factory_cls.assert_called_once_with(mock_build_dify_run_context.return_value)
mock_adapt_node_data.assert_called_once_with(node_config["data"])
mock_node_cls.validate_node_data.assert_called_once_with(sentinel.adapted_node_data)
mock_file_reference_factory_cls.assert_called_once_with(
mock_graph_init_context_cls.return_value.to_graph_init_params.return_value.run_context
)
mock_file_reference_factory_cls.assert_called_once_with(mock_build_dify_run_context.return_value)
mock_node_cls.assert_called_once_with(
node_id="n-1",
data=sentinel.node_data,
@@ -18,6 +18,8 @@ from core.app.entities.task_entities import StreamEvent
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper
from graphon.entities.pause_reason import HumanInputRequired
from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus
from graphon.nodes.human_input.entities import SelectInputConfig, StringListSource
from graphon.nodes.human_input.enums import ValueSourceType
from graphon.runtime import GraphRuntimeState, VariablePool
from models.enums import CreatorUserRole
from models.model import AppMode
@@ -106,7 +108,7 @@ def _build_snapshot(status: WorkflowNodeExecutionStatus) -> WorkflowNodeExecutio
)
def _build_resumption_context(task_id: str) -> WorkflowResumptionContext:
def _build_resumption_context(task_id: str, *, select_options: list[str] | None = None) -> WorkflowResumptionContext:
app_config = WorkflowUIBasedAppConfig(
tenant_id="tenant-1",
app_id="app-1",
@@ -125,6 +127,8 @@ def _build_resumption_context(task_id: str) -> WorkflowResumptionContext:
workflow_execution_id="run-1",
)
runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0)
if select_options is not None:
runtime_state.variable_pool.add(("start", "options"), select_options)
runtime_state.register_paused_node("node-1")
runtime_state.outputs = {"result": "value"}
wrapper = _WorkflowGenerateEntityWrapper(entity=generate_entity)
@@ -787,6 +791,59 @@ def test_build_snapshot_events_preserves_public_form_token(monkeypatch: pytest.M
assert pause_data["reasons"][0]["expiration_time"] == int(datetime(2024, 1, 1, tzinfo=UTC).timestamp())
def test_build_snapshot_events_resolves_pause_reason_select_options(monkeypatch: pytest.MonkeyPatch) -> None:
workflow_run = _build_workflow_run(WorkflowExecutionStatus.PAUSED)
snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED)
resumption_context = _build_resumption_context("task-ctx", select_options=["approve", "reject"])
monkeypatch.setattr(
service_module, "load_form_tokens_by_form_id", lambda form_ids, session=None, surface=None: {"form-1": "wtok"}
)
session_maker = _SessionMaker(
SimpleNamespace(
execute=lambda _stmt: [("form-1", datetime(2024, 1, 1, tzinfo=UTC), '{"display_in_ui": true}')],
)
)
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",
inputs=[
SelectInputConfig(
output_variable_name="decision",
option_source=StringListSource(
type=ValueSourceType.VARIABLE,
selector=["start", "options"],
value=[],
),
)
],
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,
session_maker=cast(sessionmaker[Session], session_maker),
)
human_input_event = events[-2]
assert human_input_event["data"]["inputs"][0]["option_source"]["value"] == ["approve", "reject"]
pause_event = events[-1]
assert pause_event["data"]["reasons"][0]["inputs"][0]["option_source"]["value"] == ["approve", "reject"]
def test_build_workflow_event_stream_loads_pause_tokens_without_flask_app_context(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -76,11 +76,11 @@ def test_human_input_delivery_allows_disabled_method(monkeypatch: pytest.MonkeyP
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]
node_stub.render_form_content_before_submission.return_value = "rendered"
node_stub.resolve_default_values.return_value = {}
service._build_human_input_node_for_debugging = 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", {})
return_value=("form-1", [])
)
test_service_instance = MagicMock()
@@ -112,11 +112,11 @@ def test_human_input_delivery_dispatches_to_test_service(monkeypatch: pytest.Mon
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]
node_stub.render_form_content_before_submission.return_value = "rendered"
node_stub.resolve_default_values.return_value = {}
service._build_human_input_node_for_debugging = 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", {})
return_value=("form-1", [])
)
test_service_instance = MagicMock()
@@ -151,11 +151,11 @@ def test_human_input_delivery_debug_mode_overrides_recipients(monkeypatch: pytes
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]
node_stub.render_form_content_before_submission.return_value = "rendered"
node_stub.resolve_default_values.return_value = {}
service._build_human_input_node_for_debugging = 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", {})
return_value=("form-1", [])
)
test_service_instance = MagicMock()