mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
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:
co-authored by
JzoNg
GPT 5.4
autofix-ci[bot]
-LAN-
parent
44725dde74
commit
3c98f96ae8
@@ -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()
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+131
-3
@@ -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,
|
||||
|
||||
+181
-4
@@ -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
|
||||
|
||||
+78
@@ -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
|
||||
+53
-14
@@ -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
|
||||
|
||||
+21
@@ -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"},
|
||||
},
|
||||
}
|
||||
]
|
||||
|
||||
+174
@@ -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",
|
||||
|
||||
+32
@@ -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):
|
||||
|
||||
+116
-16
@@ -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"]
|
||||
|
||||
+84
-30
@@ -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,
|
||||
|
||||
+101
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user