mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
chore(api): upgrade graphon to v0.6.0, migrate HITL logic back to Dify (#38247)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
autofix-ci[bot]
parent
cb6179356c
commit
f4ec608ef4
+8
-3
@@ -14,11 +14,16 @@ 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 core.workflow.nodes.human_input.entities import (
|
||||
FormDefinition,
|
||||
SelectInputConfig,
|
||||
StringListSource,
|
||||
UserActionConfig,
|
||||
)
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType
|
||||
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
|
||||
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
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ from core.workflow.human_input_adapter import (
|
||||
MemberRecipient,
|
||||
WebAppDeliveryMethod,
|
||||
)
|
||||
from graphon.nodes.human_input.entities import FormDefinition, HumanInputNodeData, UserActionConfig
|
||||
from core.workflow.nodes.human_input.entities import FormDefinition, HumanInputNodeData, UserActionConfig
|
||||
from models.account import (
|
||||
Account,
|
||||
AccountStatus,
|
||||
|
||||
+10
-6
@@ -13,7 +13,11 @@ from core.app.workflow.layers import PersistenceWorkflowInfo, WorkflowPersistenc
|
||||
from core.repositories.human_input_repository import HumanInputFormEntity, HumanInputFormRepository
|
||||
from core.repositories.sqlalchemy_workflow_execution_repository import SQLAlchemyWorkflowExecutionRepository
|
||||
from core.repositories.sqlalchemy_workflow_node_execution_repository import SQLAlchemyWorkflowNodeExecutionRepository
|
||||
from core.workflow.node_runtime import DifyFileReferenceFactory, DifyHumanInputNodeRuntime
|
||||
from core.workflow.nodes.human_input.callback import (
|
||||
DifyHITLCallback,
|
||||
)
|
||||
from core.workflow.nodes.human_input.entities import HumanInputNodeData, UserActionConfig
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormStatus
|
||||
from core.workflow.system_variables import build_system_variables
|
||||
from graphon.enums import WorkflowType
|
||||
from graphon.graph import Graph
|
||||
@@ -21,8 +25,6 @@ from graphon.graph_engine import GraphEngine
|
||||
from graphon.graph_engine.command_channels import InMemoryChannel
|
||||
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.human_input_node import HumanInputNode
|
||||
from graphon.nodes.start.entities import StartNodeData
|
||||
from graphon.nodes.start.start_node import StartNode
|
||||
@@ -115,14 +117,16 @@ def _build_graph(
|
||||
UserActionConfig(id="continue", title="Continue"),
|
||||
],
|
||||
)
|
||||
hitl_callback = DifyHITLCallback(
|
||||
form_repository=form_repository,
|
||||
node_data=human_data,
|
||||
)
|
||||
human_node = HumanInputNode(
|
||||
node_id="human",
|
||||
data=human_data,
|
||||
graph_init_params=params,
|
||||
graph_runtime_state=runtime_state,
|
||||
form_repository=form_repository,
|
||||
file_reference_factory=DifyFileReferenceFactory(params.run_context),
|
||||
runtime=DifyHumanInputNodeRuntime(params.run_context),
|
||||
hitl_callback=hitl_callback,
|
||||
)
|
||||
|
||||
end_data = EndNodeData(
|
||||
|
||||
@@ -5,7 +5,7 @@ from datetime import timedelta
|
||||
from decimal import Decimal
|
||||
from uuid import uuid4
|
||||
|
||||
from graphon.nodes.human_input.entities import FormDefinition, UserActionConfig
|
||||
from core.workflow.nodes.human_input.entities import FormDefinition, UserActionConfig
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.account import Account, Tenant, TenantAccountJoin
|
||||
from models.enums import ConversationFromSource, InvokeFrom
|
||||
|
||||
+233
-6
@@ -13,12 +13,13 @@ from sqlalchemy import Engine, delete, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.workflow.human_input_adapter import DeliveryMethodType
|
||||
from core.workflow.nodes.human_input.entities import FormDefinition, ParagraphInputConfig, UserActionConfig
|
||||
from core.workflow.nodes.human_input.enums import FormInputType, HumanInputFormStatus
|
||||
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
|
||||
from extensions.ext_storage import storage
|
||||
from graphon.entities import WorkflowExecution
|
||||
from graphon.entities.pause_reason import HumanInputRequired, PauseReasonType
|
||||
from graphon.entities.pause_reason import HitlRequired, PauseReasonType
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from graphon.nodes.human_input.entities import FormDefinition, ParagraphInputConfig, UserActionConfig
|
||||
from graphon.nodes.human_input.enums import FormInputType, HumanInputFormStatus
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
|
||||
from models.human_input import (
|
||||
@@ -387,6 +388,232 @@ class TestCreateWorkflowPause:
|
||||
pause_reasons=[],
|
||||
)
|
||||
|
||||
def test_create_workflow_pause_writes_hitl_type_for_dify_human_input_reason(
|
||||
self,
|
||||
repository: DifyAPISQLAlchemyWorkflowRunRepository,
|
||||
db_session_with_containers: Session,
|
||||
test_scope: _TestScope,
|
||||
) -> None:
|
||||
"""Persist Dify human-input reasons using graphon HITL rows while preserving enriched reads."""
|
||||
|
||||
workflow_run = _create_workflow_run(
|
||||
db_session_with_containers,
|
||||
test_scope,
|
||||
status=WorkflowExecutionStatus.RUNNING,
|
||||
)
|
||||
expiration_time = naive_utc_now()
|
||||
form_definition = FormDefinition(
|
||||
form_content="content",
|
||||
inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="name")],
|
||||
user_actions=[UserActionConfig(id="approve", title="Approve")],
|
||||
rendered_content="rendered",
|
||||
expiration_time=expiration_time,
|
||||
default_values={"name": "Alice"},
|
||||
node_title="Ask Name",
|
||||
display_in_ui=True,
|
||||
)
|
||||
form_model = HumanInputForm(
|
||||
tenant_id=test_scope.tenant_id,
|
||||
app_id=test_scope.app_id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
node_id="node-1",
|
||||
form_definition=form_definition.model_dump_json(),
|
||||
rendered_content="rendered",
|
||||
status=HumanInputFormStatus.WAITING,
|
||||
expiration_time=expiration_time,
|
||||
)
|
||||
db_session_with_containers.add(form_model)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
pause_entity = repository.create_workflow_pause(
|
||||
workflow_run_id=workflow_run.id,
|
||||
state_owner_user_id=test_scope.user_id,
|
||||
state='{"test": "state"}',
|
||||
pause_reasons=[
|
||||
HumanInputRequired(
|
||||
form_id=form_model.id,
|
||||
form_content="ignored-at-persistence-boundary",
|
||||
inputs=[],
|
||||
actions=[],
|
||||
node_id="node-1",
|
||||
node_title="Ask Name",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
pause_model = db_session_with_containers.get(WorkflowPause, pause_entity.id)
|
||||
assert pause_model is not None
|
||||
test_scope.state_keys.add(pause_model.state_object_key)
|
||||
|
||||
reason_models = db_session_with_containers.scalars(
|
||||
select(WorkflowPauseReason).where(WorkflowPauseReason.pause_id == pause_model.id)
|
||||
).all()
|
||||
|
||||
assert len(reason_models) == 1
|
||||
assert reason_models[0].type_ == PauseReasonType.HITL_REQUIRED
|
||||
assert reason_models[0].form_id == form_model.id
|
||||
assert reason_models[0].node_id == "node-1"
|
||||
|
||||
pause_reasons = pause_entity.get_pause_reasons()
|
||||
|
||||
assert len(pause_reasons) == 1
|
||||
reason = pause_reasons[0]
|
||||
assert isinstance(reason, HumanInputRequired)
|
||||
assert reason.form_id == form_model.id
|
||||
assert reason.node_id == "node-1"
|
||||
assert reason.node_title == "Ask Name"
|
||||
assert reason.form_content == "rendered"
|
||||
assert reason.resolved_default_values == {"name": "Alice"}
|
||||
|
||||
reloaded_pause = repository.get_workflow_pause(workflow_run.id)
|
||||
|
||||
assert reloaded_pause is not None
|
||||
reloaded_reasons = reloaded_pause.get_pause_reasons()
|
||||
assert len(reloaded_reasons) == 1
|
||||
reloaded_reason = reloaded_reasons[0]
|
||||
assert isinstance(reloaded_reason, HumanInputRequired)
|
||||
assert reloaded_reason.form_id == form_model.id
|
||||
assert reloaded_reason.node_id == "node-1"
|
||||
assert reloaded_reason.node_title == "Ask Name"
|
||||
|
||||
def test_create_workflow_pause_round_trips_graphon_hitl_reason(
|
||||
self,
|
||||
repository: DifyAPISQLAlchemyWorkflowRunRepository,
|
||||
db_session_with_containers: Session,
|
||||
test_scope: _TestScope,
|
||||
) -> None:
|
||||
"""Persist graphon HITL rows while keeping repository reads Dify-enriched."""
|
||||
|
||||
workflow_run = _create_workflow_run(
|
||||
db_session_with_containers,
|
||||
test_scope,
|
||||
status=WorkflowExecutionStatus.RUNNING,
|
||||
)
|
||||
expiration_time = naive_utc_now()
|
||||
form_definition = FormDefinition(
|
||||
form_content="content",
|
||||
inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="name")],
|
||||
user_actions=[UserActionConfig(id="approve", title="Approve")],
|
||||
rendered_content="rendered",
|
||||
expiration_time=expiration_time,
|
||||
default_values={"name": "Alice"},
|
||||
node_title="Ask Name",
|
||||
display_in_ui=True,
|
||||
)
|
||||
form_model = HumanInputForm(
|
||||
tenant_id=test_scope.tenant_id,
|
||||
app_id=test_scope.app_id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
node_id="node-1",
|
||||
form_definition=form_definition.model_dump_json(),
|
||||
rendered_content="rendered",
|
||||
status=HumanInputFormStatus.WAITING,
|
||||
expiration_time=expiration_time,
|
||||
)
|
||||
db_session_with_containers.add(form_model)
|
||||
db_session_with_containers.commit()
|
||||
|
||||
pause_entity = repository.create_workflow_pause(
|
||||
workflow_run_id=workflow_run.id,
|
||||
state_owner_user_id=test_scope.user_id,
|
||||
state='{"test": "state"}',
|
||||
pause_reasons=[
|
||||
HitlRequired(
|
||||
session_id=form_model.id,
|
||||
node_id="node-1",
|
||||
node_title="Ask Name",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
pause_model = db_session_with_containers.get(WorkflowPause, pause_entity.id)
|
||||
assert pause_model is not None
|
||||
test_scope.state_keys.add(pause_model.state_object_key)
|
||||
|
||||
reason_models = db_session_with_containers.scalars(
|
||||
select(WorkflowPauseReason).where(WorkflowPauseReason.pause_id == pause_model.id)
|
||||
).all()
|
||||
|
||||
assert len(reason_models) == 1
|
||||
assert reason_models[0].type_ == PauseReasonType.HITL_REQUIRED
|
||||
raw_reason = reason_models[0].to_entity()
|
||||
assert isinstance(raw_reason, HitlRequired)
|
||||
assert raw_reason.TYPE == PauseReasonType.HITL_REQUIRED
|
||||
assert raw_reason.session_id == form_model.id
|
||||
assert raw_reason.node_id == "node-1"
|
||||
|
||||
pause_reasons = pause_entity.get_pause_reasons()
|
||||
assert len(pause_reasons) == 1
|
||||
reason = pause_reasons[0]
|
||||
assert isinstance(reason, HumanInputRequired)
|
||||
assert reason.form_id == form_model.id
|
||||
assert reason.node_id == "node-1"
|
||||
assert reason.node_title == "Ask Name"
|
||||
|
||||
def test_get_workflow_pause_reads_legacy_human_input_reason(
|
||||
self,
|
||||
repository: DifyAPISQLAlchemyWorkflowRunRepository,
|
||||
db_session_with_containers: Session,
|
||||
test_scope: _TestScope,
|
||||
) -> None:
|
||||
"""Hydrate old legacy HITL rows into the unchanged Dify-facing payload."""
|
||||
|
||||
workflow_run = _create_workflow_run(
|
||||
db_session_with_containers,
|
||||
test_scope,
|
||||
status=WorkflowExecutionStatus.PAUSED,
|
||||
)
|
||||
expiration_time = naive_utc_now()
|
||||
form_definition = FormDefinition(
|
||||
form_content="content",
|
||||
inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="name")],
|
||||
user_actions=[UserActionConfig(id="approve", title="Approve")],
|
||||
rendered_content="rendered",
|
||||
expiration_time=expiration_time,
|
||||
default_values={"name": "Alice"},
|
||||
node_title="Ask Name",
|
||||
display_in_ui=True,
|
||||
)
|
||||
form_model = HumanInputForm(
|
||||
tenant_id=test_scope.tenant_id,
|
||||
app_id=test_scope.app_id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
node_id="node-1",
|
||||
form_definition=form_definition.model_dump_json(),
|
||||
rendered_content="rendered",
|
||||
status=HumanInputFormStatus.WAITING,
|
||||
expiration_time=expiration_time,
|
||||
)
|
||||
pause_model = WorkflowPause(
|
||||
workflow_id=test_scope.workflow_id,
|
||||
workflow_run_id=workflow_run.id,
|
||||
state_object_key=f"workflow-state-{uuid4()}.json",
|
||||
)
|
||||
db_session_with_containers.add_all([form_model, pause_model])
|
||||
db_session_with_containers.flush()
|
||||
reason_model = WorkflowPauseReason(
|
||||
pause_id=pause_model.id,
|
||||
type_=PauseReasonType.LEGACY_HUMAN_INPUT_REQUIRED,
|
||||
form_id=form_model.id,
|
||||
node_id="node-1",
|
||||
)
|
||||
db_session_with_containers.add(reason_model)
|
||||
db_session_with_containers.commit()
|
||||
test_scope.state_keys.add(pause_model.state_object_key)
|
||||
|
||||
pause_entity = repository.get_workflow_pause(workflow_run.id)
|
||||
|
||||
assert pause_entity is not None
|
||||
pause_reasons = pause_entity.get_pause_reasons()
|
||||
assert len(pause_reasons) == 1
|
||||
reason = pause_reasons[0]
|
||||
assert isinstance(reason, HumanInputRequired)
|
||||
assert reason.form_id == form_model.id
|
||||
assert reason.node_id == "node-1"
|
||||
assert reason.node_title == "Ask Name"
|
||||
assert reason.form_content == "rendered"
|
||||
assert reason.resolved_default_values == {"name": "Alice"}
|
||||
|
||||
|
||||
class TestResumeWorkflowPause:
|
||||
"""Integration tests for resume_workflow_pause."""
|
||||
@@ -715,7 +942,7 @@ class TestBuildHumanInputRequiredReason:
|
||||
|
||||
reason_model = WorkflowPauseReason(
|
||||
pause_id=pause.id,
|
||||
type_=PauseReasonType.HUMAN_INPUT_REQUIRED,
|
||||
type_=PauseReasonType.HITL_REQUIRED,
|
||||
form_id=form_model.id,
|
||||
node_id="node-1",
|
||||
message="",
|
||||
@@ -738,7 +965,7 @@ class TestBuildHumanInputRequiredReason:
|
||||
|
||||
assert isinstance(reason, HumanInputRequired)
|
||||
assert reason.node_title == "Ask Name"
|
||||
assert reason.form_content == "content"
|
||||
assert reason.form_content == "rendered"
|
||||
assert reason.inputs[0].output_variable_name == "name"
|
||||
assert reason.actions[0].id == "approve"
|
||||
assert reason.resolved_default_values == {"name": "Alice"}
|
||||
@@ -819,7 +1046,7 @@ class TestBuildHumanInputRequiredReason:
|
||||
|
||||
reason_model = WorkflowPauseReason(
|
||||
pause_id=pause.id,
|
||||
type_=PauseReasonType.HUMAN_INPUT_REQUIRED,
|
||||
type_=PauseReasonType.HITL_REQUIRED,
|
||||
form_id=form_model.id,
|
||||
node_id="node-1",
|
||||
message="",
|
||||
|
||||
+2
-2
@@ -16,8 +16,8 @@ import pytest
|
||||
from sqlalchemy import Engine, delete, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from graphon.nodes.human_input.entities import FormDefinition, UserActionConfig
|
||||
from graphon.nodes.human_input.enums import HumanInputFormStatus
|
||||
from core.workflow.nodes.human_input.entities import FormDefinition, UserActionConfig
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormStatus
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from models.enums import ConversationFromSource, InvokeFrom
|
||||
|
||||
+2
-2
@@ -16,9 +16,9 @@ from core.workflow.human_input_adapter import (
|
||||
EmailRecipients,
|
||||
ExternalRecipient,
|
||||
)
|
||||
from core.workflow.nodes.human_input.entities import FileInputConfig, HumanInputNodeData
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormKind
|
||||
from graphon.enums import BuiltinNodeTypes
|
||||
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.human_input import HumanInputForm, HumanInputFormRecipient, HumanInputFormUploadFile
|
||||
from models.model import App, AppMode, UploadFile
|
||||
|
||||
+1
-1
@@ -7,8 +7,8 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
import services.human_input_file_upload_service as service_module
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormKind
|
||||
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,
|
||||
|
||||
+3
-3
@@ -12,10 +12,10 @@ 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 core.workflow.nodes.human_input.entities import SelectInputConfig, StringListSource, UserActionConfig
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormStatus, ValueSourceType
|
||||
from core.workflow.nodes.human_input.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
|
||||
|
||||
+1
-1
@@ -18,9 +18,9 @@ from core.workflow.human_input_adapter import (
|
||||
ExternalRecipient,
|
||||
MemberRecipient,
|
||||
)
|
||||
from core.workflow.nodes.human_input.entities import HumanInputNodeData
|
||||
from extensions.ext_storage import storage
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from graphon.nodes.human_input.entities import HumanInputNodeData
|
||||
from graphon.runtime import GraphRuntimeState, VariablePool
|
||||
from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from models.enums import CreatorUserRole, WorkflowRunTriggeredFrom
|
||||
|
||||
@@ -10,9 +10,9 @@ from flask import Flask
|
||||
|
||||
from controllers.common.errors import NotFoundError
|
||||
from controllers.console.app import workflow_run as workflow_run_module
|
||||
from graphon.entities.pause_reason import HumanInputRequired
|
||||
from core.workflow.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig
|
||||
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from graphon.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig
|
||||
from models.workflow import WorkflowRun
|
||||
|
||||
|
||||
|
||||
@@ -30,12 +30,12 @@ from core.app.entities.task_entities import (
|
||||
)
|
||||
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper
|
||||
from core.workflow.human_input_policy import FormDisposition, HumanInputSurface
|
||||
from core.workflow.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig
|
||||
from core.workflow.nodes.human_input.enums import FormInputType
|
||||
from core.workflow.nodes.human_input.pause_reason import DifyHITLEventType, HumanInputRequired
|
||||
from core.workflow.system_variables import build_system_variables
|
||||
from graphon.entities import WorkflowStartReason
|
||||
from graphon.entities.pause_reason import HumanInputRequired, PauseReasonType
|
||||
from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus
|
||||
from graphon.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig
|
||||
from graphon.nodes.human_input.enums import FormInputType
|
||||
from graphon.runtime import GraphRuntimeState, VariablePool
|
||||
from models.account import Account
|
||||
from models.enums import CreatorUserRole
|
||||
@@ -115,7 +115,7 @@ def _build_advanced_chat_paused_blocking_response() -> AdvancedChatPausedBlockin
|
||||
paused_nodes=["node-1"],
|
||||
reasons=[
|
||||
{
|
||||
"type": PauseReasonType.HUMAN_INPUT_REQUIRED,
|
||||
"type": DifyHITLEventType.HUMAN_INPUT_REQUIRED.value,
|
||||
"form_id": "form-1",
|
||||
"expiration_time": 100,
|
||||
}
|
||||
@@ -384,7 +384,7 @@ class TestHitlServiceApi:
|
||||
assert response["event"] == "workflow_paused"
|
||||
assert response["workflow_run_id"] == "run-1"
|
||||
assert response["answer"] == "partial"
|
||||
assert response["data"]["reasons"][0]["type"] == PauseReasonType.HUMAN_INPUT_REQUIRED
|
||||
assert response["data"]["reasons"][0]["type"] == DifyHITLEventType.HUMAN_INPUT_REQUIRED
|
||||
assert response["data"]["reasons"][0]["expiration_time"] == 100
|
||||
assert "human_input_forms" not in response["data"]
|
||||
|
||||
@@ -476,7 +476,7 @@ class TestHitlServiceApi:
|
||||
outputs={},
|
||||
reasons=[
|
||||
{
|
||||
"type": PauseReasonType.HUMAN_INPUT_REQUIRED,
|
||||
"type": DifyHITLEventType.HUMAN_INPUT_REQUIRED.value,
|
||||
"form_id": "form-1",
|
||||
"node_id": "node-1",
|
||||
"expiration_time": 123,
|
||||
|
||||
@@ -15,8 +15,8 @@ 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 core.workflow.nodes.human_input.entities import ParagraphInputConfig, SelectInputConfig, StringListSource
|
||||
from core.workflow.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
|
||||
|
||||
+2
-2
@@ -13,7 +13,7 @@ from core.app.entities.task_entities import (
|
||||
NodeStartStreamResponse,
|
||||
PingStreamResponse,
|
||||
)
|
||||
from graphon.entities.pause_reason import PauseReasonType
|
||||
from core.workflow.nodes.human_input.pause_reason import DifyHITLEventType
|
||||
from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ class TestAdvancedChatGenerateResponseConverter:
|
||||
metadata={"usage": {"total_tokens": 1}},
|
||||
created_at=1,
|
||||
paused_nodes=["node-1"],
|
||||
reasons=[{"type": PauseReasonType.HUMAN_INPUT_REQUIRED, "form_id": "form-1"}],
|
||||
reasons=[{"TYPE": DifyHITLEventType.HUMAN_INPUT_REQUIRED, "form_id": "form-1"}],
|
||||
status=WorkflowExecutionStatus.PAUSED,
|
||||
elapsed_time=0.1,
|
||||
total_tokens=0,
|
||||
|
||||
@@ -17,7 +17,7 @@ from core.app.entities.queue_entities import (
|
||||
QueueWorkflowSucceededEvent,
|
||||
)
|
||||
from core.app.entities.task_entities import StreamEvent
|
||||
from graphon.entities.pause_reason import HumanInputRequired
|
||||
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from models.enums import MessageStatus
|
||||
from models.execution_extra_content import HumanInputContent
|
||||
|
||||
+3
-3
@@ -50,10 +50,10 @@ from core.app.entities.task_entities import (
|
||||
ReasoningChunkStreamResponse,
|
||||
)
|
||||
from core.base.tts.app_generator_tts_publisher import AudioTrunk
|
||||
from core.workflow.nodes.human_input.entities import UserActionConfig
|
||||
from core.workflow.nodes.human_input.pause_reason import DifyHITLEventType
|
||||
from core.workflow.system_variables import build_system_variables
|
||||
from graphon.entities.pause_reason import PauseReasonType
|
||||
from graphon.enums import BuiltinNodeTypes
|
||||
from graphon.nodes.human_input.entities import UserActionConfig
|
||||
from graphon.runtime import GraphRuntimeState, VariablePool
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.enums import MessageStatus
|
||||
@@ -168,7 +168,7 @@ class TestAdvancedChatGenerateTaskPipeline:
|
||||
assert response.data.paused_nodes == ["node-1"]
|
||||
assert response.data.reasons == [
|
||||
{
|
||||
"TYPE": PauseReasonType.HUMAN_INPUT_REQUIRED,
|
||||
"TYPE": DifyHITLEventType.HUMAN_INPUT_REQUIRED,
|
||||
"form_id": "form-1",
|
||||
"node_id": "node-1",
|
||||
"node_title": "Approval",
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any
|
||||
|
||||
from pytest_mock import MockerFixture
|
||||
|
||||
import graphon.nodes.human_input.entities # noqa: F401
|
||||
import core.workflow.nodes.human_input.entities # noqa: F401
|
||||
from core.app.apps.advanced_chat import app_generator as adv_app_gen_module
|
||||
from core.app.apps.workflow import app_generator as wf_app_gen_module
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
|
||||
@@ -22,8 +22,9 @@ from core.app.entities.queue_entities import (
|
||||
QueueWorkflowStartedEvent,
|
||||
QueueWorkflowSucceededEvent,
|
||||
)
|
||||
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
|
||||
from core.workflow.system_variables import default_system_variables
|
||||
from graphon.entities.pause_reason import HumanInputRequired
|
||||
from graphon.entities.pause_reason import HitlRequired
|
||||
from graphon.enums import BuiltinNodeTypes
|
||||
from graphon.graph_events import (
|
||||
GraphRunPausedEvent,
|
||||
@@ -344,10 +345,20 @@ class TestWorkflowBasedAppRunner:
|
||||
"core.app.apps.workflow_app_runner.dispatch_human_input_email_task",
|
||||
_Dispatch(),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow_app_runner.enrich_graph_pause_reasons",
|
||||
lambda **_: [
|
||||
HumanInputRequired(
|
||||
form_id="form",
|
||||
form_content="content",
|
||||
node_id="node-1",
|
||||
node_title="Node",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
reason = HumanInputRequired(
|
||||
form_id="form",
|
||||
form_content="content",
|
||||
reason = HitlRequired(
|
||||
session_id="form",
|
||||
node_id="node-1",
|
||||
node_title="Node",
|
||||
)
|
||||
|
||||
@@ -4,7 +4,8 @@ import pytest
|
||||
|
||||
from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner
|
||||
from core.app.entities.queue_entities import QueueWorkflowPausedEvent
|
||||
from graphon.entities.pause_reason import HumanInputRequired
|
||||
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
|
||||
from graphon.entities.pause_reason import HitlRequired
|
||||
from graphon.graph_events import GraphRunPausedEvent
|
||||
|
||||
|
||||
@@ -17,6 +18,8 @@ class _DummyQueueManager:
|
||||
|
||||
|
||||
class _DummyRuntimeState:
|
||||
variable_pool = object()
|
||||
|
||||
def get_paused_nodes(self):
|
||||
return ["node-1"]
|
||||
|
||||
@@ -36,7 +39,11 @@ def test_handle_pause_event_enqueues_email_task(monkeypatch: pytest.MonkeyPatch)
|
||||
runner = WorkflowBasedAppRunner(queue_manager=queue_manager, app_id="app-id")
|
||||
workflow_entry = _DummyWorkflowEntry()
|
||||
|
||||
reason = HumanInputRequired(
|
||||
graph_reason = HitlRequired(session_id="form-123", node_id="node-1", node_title="Review")
|
||||
event = GraphRunPausedEvent(reasons=[graph_reason], outputs={})
|
||||
|
||||
email_task = MagicMock()
|
||||
enriched_reason = HumanInputRequired(
|
||||
form_id="form-123",
|
||||
form_content="content",
|
||||
inputs=[],
|
||||
@@ -44,9 +51,10 @@ def test_handle_pause_event_enqueues_email_task(monkeypatch: pytest.MonkeyPatch)
|
||||
node_id="node-1",
|
||||
node_title="Review",
|
||||
)
|
||||
event = GraphRunPausedEvent(reasons=[reason], outputs={})
|
||||
|
||||
email_task = MagicMock()
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow_app_runner.enrich_graph_pause_reasons",
|
||||
lambda **_: [enriched_reason],
|
||||
)
|
||||
monkeypatch.setattr("core.app.apps.workflow_app_runner.dispatch_human_input_email_task", email_task)
|
||||
|
||||
runner._handle_event(workflow_entry, event)
|
||||
|
||||
@@ -10,17 +10,18 @@ from core.app.apps.workflow.app_runner import WorkflowAppRunner
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.app.entities.queue_entities import QueueWorkflowPausedEvent
|
||||
from core.app.entities.task_entities import HumanInputRequiredResponse, WorkflowPauseStreamResponse
|
||||
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 (
|
||||
from core.workflow.nodes.human_input.entities import (
|
||||
ParagraphInputConfig,
|
||||
SelectInputConfig,
|
||||
StringListSource,
|
||||
UserActionConfig,
|
||||
)
|
||||
from graphon.nodes.human_input.enums import ValueSourceType
|
||||
from core.workflow.nodes.human_input.enums import ValueSourceType
|
||||
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
|
||||
from core.workflow.system_variables import build_system_variables
|
||||
from graphon.entities import WorkflowStartReason
|
||||
from graphon.entities.pause_reason import HitlRequired
|
||||
from graphon.graph_events import GraphRunPausedEvent
|
||||
from graphon.runtime import GraphRuntimeState, VariablePool
|
||||
from models.account import Account
|
||||
from models.human_input import RecipientType
|
||||
@@ -56,6 +57,8 @@ class _RecordingWorkflowAppRunner(WorkflowAppRunner):
|
||||
|
||||
|
||||
class _FakeRuntimeState:
|
||||
variable_pool = object()
|
||||
|
||||
def get_paused_nodes(self):
|
||||
return ["node-pause-1"]
|
||||
|
||||
@@ -92,9 +95,19 @@ def _build_runner():
|
||||
)
|
||||
|
||||
|
||||
def test_graph_run_paused_event_emits_queue_pause_event():
|
||||
def test_graph_run_paused_event_emits_queue_pause_event(monkeypatch: pytest.MonkeyPatch):
|
||||
runner = _build_runner()
|
||||
reason = HumanInputRequired(
|
||||
graph_reason = HitlRequired(
|
||||
session_id="form-1",
|
||||
node_id="node-human",
|
||||
node_title="Human Step",
|
||||
)
|
||||
event = GraphRunPausedEvent(reasons=[graph_reason], outputs={"foo": "bar"})
|
||||
workflow_entry = SimpleNamespace(
|
||||
graph_engine=SimpleNamespace(graph_runtime_state=_FakeRuntimeState()),
|
||||
)
|
||||
|
||||
enriched_reason = HumanInputRequired(
|
||||
form_id="form-1",
|
||||
form_content="content",
|
||||
inputs=[],
|
||||
@@ -102,9 +115,9 @@ def test_graph_run_paused_event_emits_queue_pause_event():
|
||||
node_id="node-human",
|
||||
node_title="Human Step",
|
||||
)
|
||||
event = GraphRunPausedEvent(reasons=[reason], outputs={"foo": "bar"})
|
||||
workflow_entry = SimpleNamespace(
|
||||
graph_engine=SimpleNamespace(graph_runtime_state=_FakeRuntimeState()),
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow_app_runner.enrich_graph_pause_reasons",
|
||||
lambda **_: [enriched_reason],
|
||||
)
|
||||
|
||||
runner._handle_event(workflow_entry, event)
|
||||
@@ -112,7 +125,7 @@ def test_graph_run_paused_event_emits_queue_pause_event():
|
||||
assert len(runner.published_events) == 1
|
||||
queue_event = runner.published_events[0]
|
||||
assert isinstance(queue_event, QueueWorkflowPausedEvent)
|
||||
assert queue_event.reasons == [reason]
|
||||
assert queue_event.reasons == [enriched_reason]
|
||||
assert queue_event.outputs == {"foo": "bar"}
|
||||
assert queue_event.paused_nodes == ["node-pause-1"]
|
||||
|
||||
|
||||
@@ -7,14 +7,16 @@ import pytest
|
||||
|
||||
from core.app.app_config.entities import WorkflowUIBasedAppConfig
|
||||
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom, WorkflowAppGenerateEntity
|
||||
from core.app.layers import pause_state_persist_layer as pause_layer_module
|
||||
from core.app.layers.pause_state_persist_layer import (
|
||||
PauseStatePersistenceLayer,
|
||||
WorkflowResumptionContext,
|
||||
_AdvancedChatAppGenerateEntityWrapper,
|
||||
_WorkflowGenerateEntityWrapper,
|
||||
)
|
||||
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
|
||||
from core.workflow.system_variables import SystemVariableKey
|
||||
from graphon.entities.pause_reason import SchedulingPause
|
||||
from graphon.entities.pause_reason import HitlRequired, SchedulingPause
|
||||
from graphon.graph_engine.entities.commands import GraphEngineCommand
|
||||
from graphon.graph_engine.layers.base import GraphEngineLayerNotInitializedError
|
||||
from graphon.graph_events import (
|
||||
@@ -263,6 +265,63 @@ class TestPauseStatePersistenceLayer:
|
||||
|
||||
assert isinstance(pause_reasons, list)
|
||||
|
||||
def test_on_event_enriches_hitl_pause_reasons_before_persisting(self, monkeypatch: pytest.MonkeyPatch):
|
||||
session_factory = Mock(name="session_factory")
|
||||
generate_entity = self._create_generate_entity(workflow_execution_id="run-123")
|
||||
layer = PauseStatePersistenceLayer(
|
||||
session_factory=session_factory,
|
||||
state_owner_user_id="owner-123",
|
||||
generate_entity=generate_entity,
|
||||
)
|
||||
|
||||
mock_repo = Mock()
|
||||
mock_factory = Mock(return_value=mock_repo)
|
||||
mock_form_repository = Mock(name="form_repository")
|
||||
enriched_reason = HumanInputRequired(
|
||||
form_id="form-123",
|
||||
form_content="Rendered content",
|
||||
inputs=[],
|
||||
actions=[],
|
||||
node_id="node-123",
|
||||
node_title="Ask for approval",
|
||||
)
|
||||
enrich_mock = Mock(return_value=[enriched_reason])
|
||||
monkeypatch.setattr(DifyAPIRepositoryFactory, "create_api_workflow_run_repository", mock_factory)
|
||||
monkeypatch.setattr(
|
||||
pause_layer_module,
|
||||
"HumanInputFormSubmissionRepository",
|
||||
Mock(return_value=mock_form_repository),
|
||||
raising=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
pause_layer_module,
|
||||
"enrich_graph_pause_reasons",
|
||||
enrich_mock,
|
||||
raising=False,
|
||||
)
|
||||
|
||||
graph_runtime_state = MockReadOnlyGraphRuntimeState(
|
||||
workflow_execution_id="run-123",
|
||||
)
|
||||
command_channel = MockCommandChannel()
|
||||
layer.initialize(graph_runtime_state, command_channel)
|
||||
|
||||
raw_reason = HitlRequired(
|
||||
session_id="session-123",
|
||||
node_id="node-123",
|
||||
node_title="Ask for approval",
|
||||
)
|
||||
event = GraphRunPausedEvent(reasons=[raw_reason], outputs={})
|
||||
|
||||
layer.on_event(event)
|
||||
|
||||
enrich_mock.assert_called_once_with(
|
||||
reasons=[raw_reason],
|
||||
form_repository=mock_form_repository,
|
||||
variable_pool=graph_runtime_state.variable_pool,
|
||||
)
|
||||
assert mock_repo.create_workflow_pause.call_args.kwargs["pause_reasons"] == [enriched_reason]
|
||||
|
||||
def test_on_event_ignores_non_paused_events(self, monkeypatch: pytest.MonkeyPatch):
|
||||
session_factory = Mock(name="session_factory")
|
||||
layer = PauseStatePersistenceLayer(
|
||||
|
||||
@@ -4,7 +4,7 @@ from core.entities.execution_extra_content import (
|
||||
HumanInputFormDefinition,
|
||||
HumanInputFormSubmissionData,
|
||||
)
|
||||
from graphon.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig
|
||||
from core.workflow.nodes.human_input.entities import ParagraphInputConfig, UserActionConfig
|
||||
from models.execution_extra_content import ExecutionContentType
|
||||
|
||||
|
||||
|
||||
@@ -21,11 +21,11 @@ from core.workflow.human_input_adapter import (
|
||||
ExternalRecipient,
|
||||
MemberRecipient,
|
||||
)
|
||||
from graphon.nodes.human_input.entities import (
|
||||
from core.workflow.nodes.human_input.entities import (
|
||||
FormDefinition,
|
||||
UserActionConfig,
|
||||
)
|
||||
from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.human_input import (
|
||||
EmailExternalRecipientPayload,
|
||||
|
||||
@@ -29,8 +29,8 @@ from core.workflow.human_input_adapter import (
|
||||
MemberRecipient,
|
||||
WebAppDeliveryMethod,
|
||||
)
|
||||
from graphon.nodes.human_input.entities import HumanInputNodeData, UserActionConfig
|
||||
from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
|
||||
from core.workflow.nodes.human_input.entities import HumanInputNodeData, UserActionConfig
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.human_input import HumanInputFormRecipient, RecipientType
|
||||
|
||||
|
||||
+29
-15
@@ -10,6 +10,18 @@ from core.repositories.human_input_repository import (
|
||||
HumanInputFormRepository,
|
||||
)
|
||||
from core.workflow.node_runtime import DifyHumanInputNodeRuntime
|
||||
from core.workflow.nodes.human_input.callback import (
|
||||
DifyHITLCallback,
|
||||
)
|
||||
from core.workflow.nodes.human_input.entities import (
|
||||
FileInputConfig,
|
||||
FileListInputConfig,
|
||||
HumanInputNodeData,
|
||||
SelectInputConfig,
|
||||
StringListSource,
|
||||
UserActionConfig,
|
||||
)
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormStatus, ValueSourceType
|
||||
from core.workflow.system_variables import build_system_variables
|
||||
from graphon.entities import WorkflowStartReason
|
||||
from graphon.file import File, FileTransferMethod, FileType
|
||||
@@ -25,15 +37,6 @@ 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 (
|
||||
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
|
||||
@@ -83,6 +86,7 @@ class StaticForm(HumanInputFormEntity):
|
||||
action_id: str | None = None
|
||||
data: Mapping[str, Any] | None = None
|
||||
status_value: HumanInputFormStatus = HumanInputFormStatus.WAITING
|
||||
created: datetime = naive_utc_now()
|
||||
expiration: datetime = naive_utc_now() + timedelta(days=1)
|
||||
|
||||
@property
|
||||
@@ -105,6 +109,10 @@ class StaticForm(HumanInputFormEntity):
|
||||
def selected_action_id(self) -> str | None:
|
||||
return self.action_id
|
||||
|
||||
@property
|
||||
def created_at(self) -> datetime:
|
||||
return self.created
|
||||
|
||||
@property
|
||||
def submitted_data(self) -> Mapping[str, Any] | None:
|
||||
return self.data
|
||||
@@ -188,27 +196,33 @@ def _build_graph(runtime_state: GraphRuntimeState, repo: HumanInputFormRepositor
|
||||
human_a_config = {"id": "human_a", "data": human_data.model_dump()}
|
||||
human_a_runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context)
|
||||
human_a_runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined]
|
||||
human_a_callback = DifyHITLCallback(
|
||||
form_repository=repo,
|
||||
node_data=human_data,
|
||||
file_reference_factory=_TestFileReferenceFactory(),
|
||||
)
|
||||
human_a = HumanInputNode(
|
||||
node_id=human_a_config["id"],
|
||||
data=human_data,
|
||||
graph_init_params=graph_init_params,
|
||||
graph_runtime_state=runtime_state,
|
||||
form_repository=repo,
|
||||
file_reference_factory=_TestFileReferenceFactory(),
|
||||
runtime=human_a_runtime,
|
||||
hitl_callback=human_a_callback,
|
||||
)
|
||||
|
||||
human_b_config = {"id": "human_b", "data": human_data.model_dump()}
|
||||
human_b_runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context)
|
||||
human_b_runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined]
|
||||
human_b_callback = DifyHITLCallback(
|
||||
form_repository=repo,
|
||||
node_data=human_data,
|
||||
file_reference_factory=_TestFileReferenceFactory(),
|
||||
)
|
||||
human_b = HumanInputNode(
|
||||
node_id=human_b_config["id"],
|
||||
data=human_data,
|
||||
graph_init_params=graph_init_params,
|
||||
graph_runtime_state=runtime_state,
|
||||
form_repository=repo,
|
||||
file_reference_factory=_TestFileReferenceFactory(),
|
||||
runtime=human_b_runtime,
|
||||
hitl_callback=human_b_callback,
|
||||
)
|
||||
|
||||
end_data = EndNodeData(
|
||||
|
||||
@@ -27,12 +27,12 @@ from core.workflow.nodes.agent_v2.session_store import (
|
||||
WorkflowAgentRuntimeSessionStore,
|
||||
WorkflowAgentSessionScope,
|
||||
)
|
||||
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
|
||||
from graphon.entities import GraphInitParams
|
||||
from graphon.entities.pause_reason import HumanInputRequired
|
||||
from graphon.entities.pause_reason import HitlRequired
|
||||
from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionMetadataKey, WorkflowNodeExecutionStatus
|
||||
from graphon.file import File, FileTransferMethod, FileType
|
||||
from graphon.node_events import PauseRequestedEvent, StreamCompletedEvent
|
||||
from graphon.nodes.human_input.entities import UserActionConfig
|
||||
from graphon.runtime import GraphRuntimeState
|
||||
from graphon.variables.segments import ArrayFileSegment, FileSegment, StringSegment
|
||||
from models.agent import Agent, AgentConfigSnapshot, WorkflowAgentNodeBinding
|
||||
@@ -510,7 +510,7 @@ def test_agent_node_paused_run_requests_workflow_pause_and_persists_snapshot():
|
||||
node = _node(scenario=FakeAgentBackendScenario.PAUSED, session_store=store)
|
||||
|
||||
# ENG-636: the PAUSED scenario emits a dify.ask_human deferred call, so the
|
||||
# node now builds a HITL form and pauses with HumanInputRequired. Stub the
|
||||
# node now builds a HITL form and pauses with HitlRequired. Stub the
|
||||
# form repository so the unit test stays DB-free.
|
||||
fake_repo = MagicMock()
|
||||
fake_repo.create_form.return_value = MagicMock(id="form-1")
|
||||
@@ -520,8 +520,8 @@ def test_agent_node_paused_run_requests_workflow_pause_and_persists_snapshot():
|
||||
|
||||
assert len(events) == 1
|
||||
assert isinstance(events[0], PauseRequestedEvent)
|
||||
assert isinstance(events[0].reason, HumanInputRequired)
|
||||
assert events[0].reason.form_id == "form-1"
|
||||
assert isinstance(events[0].reason, HitlRequired)
|
||||
assert events[0].reason.session_id == "form-1"
|
||||
assert events[0].reason.node_id == "agent-node"
|
||||
fake_repo.create_form.assert_called_once()
|
||||
assert store.saved
|
||||
@@ -585,7 +585,7 @@ def test_agent_node_repauses_when_resumed_form_still_waiting(monkeypatch):
|
||||
form_id="form-1",
|
||||
form_content="Approve?",
|
||||
inputs=[],
|
||||
actions=[UserActionConfig(id="ok", title="OK")],
|
||||
actions=[],
|
||||
node_id="agent-node",
|
||||
node_title="Budget review",
|
||||
)
|
||||
@@ -601,7 +601,7 @@ def test_agent_node_repauses_when_resumed_form_still_waiting(monkeypatch):
|
||||
|
||||
assert len(events) == 1
|
||||
assert isinstance(events[0], PauseRequestedEvent)
|
||||
assert isinstance(events[0].reason, HumanInputRequired)
|
||||
assert isinstance(events[0].reason, HitlRequired)
|
||||
assert client.request is None # no second Agent run was created
|
||||
|
||||
|
||||
|
||||
@@ -22,13 +22,14 @@ from core.workflow.nodes.agent_v2.ask_human_hitl import (
|
||||
build_delivery_methods,
|
||||
parse_ask_human_args,
|
||||
)
|
||||
from graphon.nodes.human_input.entities import (
|
||||
from core.workflow.nodes.human_input.entities import (
|
||||
FileInputConfig,
|
||||
FileListInputConfig,
|
||||
ParagraphInputConfig,
|
||||
SelectInputConfig,
|
||||
)
|
||||
from graphon.nodes.human_input.enums import ButtonStyle, TimeoutUnit
|
||||
from core.workflow.nodes.human_input.enums import ButtonStyle, TimeoutUnit
|
||||
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
|
||||
from models.agent_config_entities import AgentHumanContactConfig
|
||||
|
||||
|
||||
@@ -229,7 +230,7 @@ def test_pause_reason_requires_workflow_run_id() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_pause_reason_builds_form_and_returns_human_input_required() -> None:
|
||||
def test_pause_reason_builds_form_and_returns_dify_pause_reason() -> None:
|
||||
repo = _fake_repository(form_id="form-xyz")
|
||||
contacts = [AgentHumanContactConfig(email="a@x.com")]
|
||||
|
||||
@@ -251,6 +252,7 @@ def test_pause_reason_builds_form_and_returns_human_input_required() -> None:
|
||||
|
||||
assert result is not None
|
||||
assert result.form_id == "form-xyz"
|
||||
assert isinstance(result, HumanInputRequired)
|
||||
assert result.node_id == "node-1"
|
||||
assert result.node_title == "Approve?" # args.title wins over default
|
||||
assert [i.output_variable_name for i in result.inputs] == ["note"]
|
||||
@@ -321,6 +323,7 @@ def test_pause_reason_select_default_flows_into_resolved_defaults() -> None:
|
||||
repository=repo,
|
||||
)
|
||||
assert result is not None
|
||||
params: FormCreateParams = repo.create_form.call_args.args[0]
|
||||
assert result.resolved_default_values == {"tier": "t1"}
|
||||
|
||||
|
||||
|
||||
@@ -4,15 +4,16 @@ from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import pytest
|
||||
from dify_agent.layers.ask_human import AskHumanToolResult
|
||||
|
||||
from core.workflow.nodes.agent_v2.ask_human_resume import (
|
||||
build_deferred_tool_results,
|
||||
map_form_to_outcome,
|
||||
)
|
||||
from graphon.entities.pause_reason import HumanInputRequired
|
||||
from graphon.nodes.human_input.entities import FormDefinition, ParagraphInputConfig, UserActionConfig
|
||||
from graphon.nodes.human_input.enums import HumanInputFormStatus
|
||||
from core.workflow.nodes.human_input.entities import FormDefinition, ParagraphInputConfig, UserActionConfig
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormStatus
|
||||
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
|
||||
|
||||
|
||||
def _form_definition_json() -> str:
|
||||
@@ -64,18 +65,17 @@ def test_map_timeout_form_to_timeout_result() -> None:
|
||||
assert outcome.deferred_result.action is None
|
||||
|
||||
|
||||
def test_map_expired_form_to_timeout_result() -> None:
|
||||
outcome = map_form_to_outcome(
|
||||
status=HumanInputFormStatus.EXPIRED,
|
||||
selected_action_id=None,
|
||||
submitted_data=None,
|
||||
rendered_content="x",
|
||||
form_definition=_form_definition_json(),
|
||||
form_id="form-1",
|
||||
node_id="node-1",
|
||||
)
|
||||
assert outcome.deferred_result is not None
|
||||
assert outcome.deferred_result.status == "timeout"
|
||||
def test_map_expired_form_rejects_invalid_resume_state() -> None:
|
||||
with pytest.raises(AssertionError, match="globally expired ask_human form"):
|
||||
map_form_to_outcome(
|
||||
status=HumanInputFormStatus.EXPIRED,
|
||||
selected_action_id=None,
|
||||
submitted_data=None,
|
||||
rendered_content="x",
|
||||
form_definition=_form_definition_json(),
|
||||
form_id="form-1",
|
||||
node_id="node-1",
|
||||
)
|
||||
|
||||
|
||||
def test_map_waiting_form_rebuilds_pause() -> None:
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from core.workflow.nodes.human_input._exc import ExtensionsNotSetErrorValueError
|
||||
from core.workflow.nodes.human_input.entities import (
|
||||
FileInputConfig,
|
||||
FormDefinition,
|
||||
ParagraphInputConfig,
|
||||
SelectInputConfig,
|
||||
UserActionConfig,
|
||||
)
|
||||
from core.workflow.nodes.human_input.enums import ButtonStyle, FormInputType, ValueSourceType
|
||||
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
|
||||
from core.workflow.nodes.human_input.session_binding import SessionBinding
|
||||
from graphon.file import FileType
|
||||
|
||||
|
||||
def test_session_binding_identity_mapping() -> None:
|
||||
binding = SessionBinding()
|
||||
|
||||
assert binding.issue_session_id_for_form(form_id="form-1") == "form-1"
|
||||
assert binding.resolve_form_id_from_session_id(session_id="form-1") == "form-1"
|
||||
|
||||
|
||||
def test_human_input_node_contracts_accept_legacy_json_payload() -> None:
|
||||
payload = {
|
||||
"form_content": "Please confirm",
|
||||
"inputs": [
|
||||
{
|
||||
"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"],
|
||||
},
|
||||
},
|
||||
],
|
||||
"user_actions": [
|
||||
{
|
||||
"id": "approve",
|
||||
"title": "Approve",
|
||||
"button_style": "primary",
|
||||
}
|
||||
],
|
||||
"rendered_content": "Please confirm",
|
||||
"expiration_time": "2024-01-01T00:00:00Z",
|
||||
"default_values": {"name": "Alice"},
|
||||
"node_title": "Human Input",
|
||||
"display_in_ui": True,
|
||||
}
|
||||
|
||||
restored = TypeAdapter(FormDefinition).validate_json(json.dumps(payload))
|
||||
|
||||
assert isinstance(restored.inputs[0], ParagraphInputConfig)
|
||||
assert isinstance(restored.inputs[1], SelectInputConfig)
|
||||
assert restored.inputs[0].default is not None
|
||||
assert restored.inputs[0].default.type == ValueSourceType.CONSTANT
|
||||
assert restored.inputs[0].default.value == "Alice"
|
||||
assert restored.inputs[1].option_source.type == ValueSourceType.CONSTANT
|
||||
assert restored.inputs[1].option_source.value == ["approve", "reject"]
|
||||
assert restored.user_actions == [UserActionConfig(id="approve", title="Approve", button_style=ButtonStyle.PRIMARY)]
|
||||
|
||||
|
||||
def test_human_input_required_pause_reason_keeps_legacy_payload_shape() -> None:
|
||||
payload = {
|
||||
"TYPE": "human_input_required",
|
||||
"form_id": "form-1",
|
||||
"form_content": "Please confirm",
|
||||
"inputs": [
|
||||
{
|
||||
"type": "paragraph",
|
||||
"output_variable_name": "name",
|
||||
"default": {
|
||||
"type": "constant",
|
||||
"selector": [],
|
||||
"value": "Alice",
|
||||
},
|
||||
}
|
||||
],
|
||||
"actions": [
|
||||
{
|
||||
"id": "approve",
|
||||
"title": "Approve",
|
||||
"button_style": "primary",
|
||||
}
|
||||
],
|
||||
"node_id": "node-1",
|
||||
"node_title": "Human Input",
|
||||
"resolved_default_values": {"name": "Alice"},
|
||||
}
|
||||
|
||||
restored = TypeAdapter(HumanInputRequired).validate_json(json.dumps(payload))
|
||||
|
||||
assert restored.TYPE.value == "human_input_required"
|
||||
assert restored.actions[0].button_style == ButtonStyle.PRIMARY
|
||||
assert restored.model_dump(mode="json")["TYPE"] == "human_input_required"
|
||||
|
||||
|
||||
def test_form_definition_dump_keeps_public_json_shape() -> None:
|
||||
definition = FormDefinition(
|
||||
form_content="Please confirm",
|
||||
inputs=[ParagraphInputConfig(type=FormInputType.PARAGRAPH, output_variable_name="name")],
|
||||
user_actions=[UserActionConfig(id="approve", title="Approve")],
|
||||
rendered_content="rendered",
|
||||
expiration_time=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
default_values={"name": "Alice"},
|
||||
node_title="Ask Name",
|
||||
display_in_ui=True,
|
||||
)
|
||||
|
||||
payload = definition.model_dump(mode="json")
|
||||
|
||||
assert payload["expiration_time"] == "2024-01-01T00:00:00Z"
|
||||
assert payload["user_actions"][0]["id"] == "approve"
|
||||
assert payload["inputs"][0]["type"] == "paragraph"
|
||||
|
||||
|
||||
def test_custom_file_input_requires_extensions() -> None:
|
||||
with pytest.raises(ExtensionsNotSetErrorValueError):
|
||||
FileInputConfig(
|
||||
output_variable_name="attachment",
|
||||
allowed_file_types=[FileType.CUSTOM],
|
||||
allowed_file_extensions=[],
|
||||
)
|
||||
@@ -31,12 +31,10 @@ from core.workflow.human_input_adapter import (
|
||||
_WebAppDeliveryConfig,
|
||||
)
|
||||
from core.workflow.node_runtime import 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 (
|
||||
from core.workflow.nodes.human_input.callback import (
|
||||
DifyHITLCallback,
|
||||
)
|
||||
from core.workflow.nodes.human_input.entities import (
|
||||
FileInputConfig,
|
||||
FileListInputConfig,
|
||||
HumanInputNodeData,
|
||||
@@ -46,13 +44,18 @@ from graphon.nodes.human_input.entities import (
|
||||
StringSource,
|
||||
UserActionConfig,
|
||||
)
|
||||
from graphon.nodes.human_input.enums import (
|
||||
from core.workflow.nodes.human_input.enums import (
|
||||
ButtonStyle,
|
||||
FormInputType,
|
||||
HumanInputFormStatus,
|
||||
TimeoutUnit,
|
||||
ValueSourceType,
|
||||
)
|
||||
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.human_input_node import HumanInputNode
|
||||
from graphon.nodes.protocols import FileReferenceFactoryProtocol
|
||||
from graphon.runtime import GraphRuntimeState, VariablePool
|
||||
@@ -69,6 +72,7 @@ class _InMemoryFormEntity(HumanInputFormEntity):
|
||||
data: Mapping[str, Any] | None = None
|
||||
is_submitted: bool = False
|
||||
status_value: HumanInputFormStatus = HumanInputFormStatus.WAITING
|
||||
created: datetime = field(default_factory=naive_utc_now)
|
||||
expiration: datetime = field(default_factory=lambda: naive_utc_now() + timedelta(days=1))
|
||||
|
||||
@property
|
||||
@@ -91,6 +95,10 @@ class _InMemoryFormEntity(HumanInputFormEntity):
|
||||
def selected_action_id(self) -> str | None:
|
||||
return self.action_id
|
||||
|
||||
@property
|
||||
def created_at(self) -> datetime:
|
||||
return self.created
|
||||
|
||||
@property
|
||||
def submitted_data(self) -> Mapping[str, Any] | None:
|
||||
return self.data
|
||||
@@ -172,13 +180,19 @@ def _build_human_input_node(
|
||||
node_data if isinstance(node_data, HumanInputNodeData) else HumanInputNodeData.model_validate(node_data)
|
||||
)
|
||||
runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined]
|
||||
callback = DifyHITLCallback(
|
||||
form_repository=runtime.build_form_repository(),
|
||||
node_data=typed_node_data,
|
||||
delivery_methods=runtime._resolve_delivery_methods(node_data=typed_node_data),
|
||||
display_in_ui=runtime._display_in_ui(node_data=typed_node_data),
|
||||
file_reference_factory=_TestFileReferenceFactory(),
|
||||
)
|
||||
return HumanInputNode(
|
||||
node_id=node_id,
|
||||
data=typed_node_data,
|
||||
graph_init_params=graph_init_params,
|
||||
graph_runtime_state=graph_runtime_state,
|
||||
file_reference_factory=_TestFileReferenceFactory(),
|
||||
runtime=runtime,
|
||||
hitl_callback=callback,
|
||||
)
|
||||
|
||||
|
||||
@@ -523,7 +537,8 @@ class TestHumanInputNodeVariableResolution:
|
||||
|
||||
assert isinstance(pause_event, PauseRequestedEvent)
|
||||
expected_values = {"user_name": "Jane Doe"}
|
||||
assert pause_event.reason.resolved_default_values == expected_values
|
||||
create_params = mock_repo.create_form.call_args.args[0]
|
||||
assert create_params.resolved_default_values == expected_values
|
||||
|
||||
params = mock_repo.create_form.call_args.args[0]
|
||||
assert params.resolved_default_values == expected_values
|
||||
|
||||
+37
-38
@@ -4,17 +4,10 @@ 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 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 core.workflow.nodes.human_input.callback import (
|
||||
DifyHITLCallback,
|
||||
)
|
||||
from graphon.nodes.human_input.entities import (
|
||||
from core.workflow.nodes.human_input.entities import (
|
||||
FileInputConfig,
|
||||
FileListInputConfig,
|
||||
HumanInputNodeData,
|
||||
@@ -23,7 +16,15 @@ from graphon.nodes.human_input.entities import (
|
||||
StringListSource,
|
||||
UserActionConfig,
|
||||
)
|
||||
from graphon.nodes.human_input.enums import HumanInputFormStatus
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormStatus
|
||||
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 (
|
||||
NodeRunStartedEvent,
|
||||
NodeRunSucceededEvent,
|
||||
)
|
||||
from graphon.nodes.human_input.human_input_node import HumanInputNode
|
||||
from graphon.nodes.protocols import FileReferenceFactoryProtocol
|
||||
from graphon.runtime import GraphRuntimeState, VariablePool
|
||||
@@ -67,16 +68,17 @@ def _create_human_input_node(
|
||||
if isinstance(config["data"], HumanInputNodeData)
|
||||
else HumanInputNodeData.model_validate(config["data"])
|
||||
)
|
||||
runtime = DifyHumanInputNodeRuntime(graph_init_params.run_context)
|
||||
runtime._file_reference_factory = _TestFileReferenceFactory() # type: ignore[attr-defined]
|
||||
callback = DifyHITLCallback(
|
||||
form_repository=repo,
|
||||
node_data=node_data,
|
||||
file_reference_factory=_TestFileReferenceFactory(),
|
||||
)
|
||||
return HumanInputNode(
|
||||
node_id=config["id"],
|
||||
data=node_data,
|
||||
graph_init_params=graph_init_params,
|
||||
graph_runtime_state=graph_runtime_state,
|
||||
form_repository=repo,
|
||||
file_reference_factory=_TestFileReferenceFactory(),
|
||||
runtime=runtime,
|
||||
hitl_callback=callback,
|
||||
)
|
||||
|
||||
|
||||
@@ -232,26 +234,25 @@ def test_human_input_node_emits_form_filled_event_before_succeeded():
|
||||
events = list(node.run())
|
||||
|
||||
assert isinstance(events[0], NodeRunStartedEvent)
|
||||
assert isinstance(events[1], NodeRunHumanInputFormFilledEvent)
|
||||
assert isinstance(events[1], NodeRunSucceededEvent)
|
||||
|
||||
filled_event = events[1]
|
||||
assert filled_event.node_title == "Human Input"
|
||||
assert filled_event.rendered_content == (
|
||||
"Please enter your name:\n\nAlice\nDecision: approve\nAttachment: [file]\nAttachments: [1 files]"
|
||||
completed_event = events[1]
|
||||
assert completed_event.node_run_result.outputs["__rendered_content"] == StringSegment(
|
||||
value="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
|
||||
assert completed_event.node_run_result.outputs["__action_id"] == StringSegment(value="Accept")
|
||||
assert completed_event.node_run_result.outputs["__action_value"] == StringSegment(value="Approve")
|
||||
assert completed_event.node_run_result.inputs["name"] == StringSegment(value="Alice")
|
||||
assert completed_event.node_run_result.inputs["decision"] == StringSegment(value="approve")
|
||||
assert isinstance(completed_event.node_run_result.inputs["attachment"], FileSegment)
|
||||
assert completed_event.node_run_result.inputs["attachment"].value_type == SegmentType.FILE
|
||||
assert completed_event.node_run_result.inputs["attachment"].value.filename == "resume.pdf"
|
||||
assert completed_event.node_run_result.inputs["attachment"].value.type == FileType.DOCUMENT
|
||||
assert completed_event.node_run_result.inputs["attachment"].value.transfer_method == FileTransferMethod.REMOTE_URL
|
||||
assert isinstance(completed_event.node_run_result.inputs["attachments"], ArrayFileSegment)
|
||||
assert completed_event.node_run_result.inputs["attachments"].value_type == SegmentType.ARRAY_FILE
|
||||
assert completed_event.node_run_result.inputs["attachments"].value[0].filename == "a.png"
|
||||
assert completed_event.node_run_result.inputs["attachments"].value[0].type == FileType.IMAGE
|
||||
|
||||
|
||||
def test_human_input_node_emits_timeout_event_before_succeeded():
|
||||
@@ -260,7 +261,5 @@ def test_human_input_node_emits_timeout_event_before_succeeded():
|
||||
events = list(node.run())
|
||||
|
||||
assert isinstance(events[0], NodeRunStartedEvent)
|
||||
assert isinstance(events[1], NodeRunHumanInputFormTimeoutEvent)
|
||||
|
||||
timeout_event = events[1]
|
||||
assert timeout_event.node_title == "Human Input"
|
||||
assert isinstance(events[1], NodeRunSucceededEvent)
|
||||
assert events[1].node_run_result.edge_source_handle == "__timeout__"
|
||||
|
||||
@@ -242,7 +242,7 @@ def test_extract_text_from_pdf(mock_pdf_document):
|
||||
mock_text_page = Mock()
|
||||
mock_text_page.get_text_range.return_value = "PDF content"
|
||||
mock_page.get_textpage.return_value = mock_text_page
|
||||
mock_pdf_document.return_value = [mock_page]
|
||||
mock_pdf_document.return_value.__enter__.return_value = [mock_page]
|
||||
text = _extract_text_from_pdf(b"%PDF-1.5\n%Test PDF content")
|
||||
assert text == "PDF content"
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.repositories.human_input_repository import HumanInputFormSubmissionRepository
|
||||
from core.workflow.human_input_policy import FormDisposition, enrich_human_input_pause_reasons
|
||||
from graphon.entities.pause_reason import PauseReasonType
|
||||
from core.workflow.nodes.human_input.boundary import enrich_graph_pause_reasons
|
||||
from core.workflow.nodes.human_input.pause_reason import DifyHITLEventType
|
||||
from graphon.entities.pause_reason import HitlRequired
|
||||
|
||||
_HUMAN_INPUT_REASON = {"TYPE": PauseReasonType.HUMAN_INPUT_REQUIRED, "form_id": "f1"}
|
||||
_HUMAN_INPUT_REASON = {"TYPE": DifyHITLEventType.HUMAN_INPUT_REQUIRED, "form_id": "f1"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -61,3 +66,21 @@ def test_pause_reason_payload_carries_approval_channels_through_factory():
|
||||
|
||||
assert payload.approval_channels == ["console"]
|
||||
assert payload.form_token is None
|
||||
|
||||
|
||||
def test_enrich_graph_pause_reasons_raises_when_hitl_form_record_is_missing():
|
||||
form_repository = Mock(spec=HumanInputFormSubmissionRepository)
|
||||
form_repository.get_by_form_id.return_value = None
|
||||
|
||||
with pytest.raises(LookupError, match="form-123"):
|
||||
enrich_graph_pause_reasons(
|
||||
reasons=[
|
||||
HitlRequired(
|
||||
session_id="form-123",
|
||||
node_id="node-1",
|
||||
node_title="Ask Name",
|
||||
)
|
||||
],
|
||||
form_repository=form_repository,
|
||||
variable_pool=None,
|
||||
)
|
||||
|
||||
@@ -8,13 +8,14 @@ from core.entities.execution_extra_content import (
|
||||
HumanInputContent,
|
||||
HumanInputFormDefinition,
|
||||
)
|
||||
from graphon.entities.pause_reason import HumanInputRequired
|
||||
from graphon.nodes.human_input.entities import (
|
||||
from core.workflow.nodes.human_input.entities import (
|
||||
FormDefinition,
|
||||
FormInputConfig,
|
||||
HumanInputNodeData,
|
||||
)
|
||||
from graphon.nodes.human_input.enums import ButtonStyle, TimeoutUnit, ValueSourceType
|
||||
from core.workflow.nodes.human_input.enums import ButtonStyle, TimeoutUnit, ValueSourceType
|
||||
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
|
||||
from core.workflow.nodes.human_input.session_binding import SessionBinding
|
||||
|
||||
|
||||
def _legacy_form_input_payloads() -> list[dict[str, Any]]:
|
||||
@@ -336,3 +337,10 @@ def test_human_input_required_response_accepts_current_serialized_payload() -> N
|
||||
assert restored.data.inputs[1].output_variable_name == "decision"
|
||||
assert restored.data.actions[0].id == "approve"
|
||||
assert restored.event == "human_input_required"
|
||||
|
||||
|
||||
def test_session_binding_identity_mapping() -> None:
|
||||
binding = SessionBinding()
|
||||
|
||||
assert binding.issue_session_id_for_form(form_id="form-1") == "form-1"
|
||||
assert binding.resolve_form_id_from_session_id(session_id="form-1") == "form-1"
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.repositories.human_input_repository import FormCreateParams, HumanInputFormRepository
|
||||
from core.workflow.nodes.human_input.callback import DifyHITLCallback
|
||||
from core.workflow.nodes.human_input.entities import HumanInputNodeData, ParagraphInputConfig, UserActionConfig
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormStatus
|
||||
from core.workflow.nodes.human_input.session_binding import SessionBinding
|
||||
from graphon.runtime import VariablePool
|
||||
from graphon.variables.factory import build_segment
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Context:
|
||||
workflow_execution_id: str
|
||||
node_id: str
|
||||
node_title: str = "Human Input"
|
||||
variable_pool: VariablePool = field(default_factory=VariablePool)
|
||||
|
||||
|
||||
def _ctx(workflow_execution_id: str, node_id: str, node_title: str = "Human Input") -> _Context:
|
||||
return _Context(workflow_execution_id=workflow_execution_id, node_id=node_id, node_title=node_title)
|
||||
|
||||
|
||||
def test_session_binding_identity_mapping() -> None:
|
||||
binding = SessionBinding()
|
||||
|
||||
assert binding.issue_session_id_for_form(form_id="form-1") == "form-1"
|
||||
assert binding.resolve_form_id_from_session_id(session_id="form-1") == "form-1"
|
||||
|
||||
|
||||
def test_dify_hitl_callback_creates_pause_requested_for_new_form() -> None:
|
||||
repository = MagicMock(spec=HumanInputFormRepository)
|
||||
repository.get_form.return_value = None
|
||||
repository.create_form.return_value = SimpleNamespace(id="form-1")
|
||||
callback = DifyHITLCallback(
|
||||
form_repository=repository,
|
||||
node_data=HumanInputNodeData(
|
||||
title="Approval",
|
||||
form_content="Please approve",
|
||||
inputs=[ParagraphInputConfig(output_variable_name="answer")],
|
||||
user_actions=[UserActionConfig(id="approve", title="Approve")],
|
||||
),
|
||||
workflow_execution_id="run-1",
|
||||
)
|
||||
|
||||
decision = callback(_ctx("run-1", "node-1"))
|
||||
|
||||
assert decision == callback.pause_requested_type(session_id="form-1")
|
||||
params: FormCreateParams = repository.create_form.call_args.args[0]
|
||||
assert params.workflow_execution_id == "run-1"
|
||||
assert params.node_id == "node-1"
|
||||
|
||||
|
||||
def test_dify_hitl_callback_returns_completed_for_submitted_form() -> None:
|
||||
repository = MagicMock(spec=HumanInputFormRepository)
|
||||
repository.get_form.return_value = SimpleNamespace(
|
||||
id="form-1",
|
||||
rendered_content="<p>Please approve</p>",
|
||||
selected_action_id="approve",
|
||||
submitted_data={"answer": "yes"},
|
||||
submitted=True,
|
||||
status=HumanInputFormStatus.SUBMITTED,
|
||||
expiration_time=naive_utc_now() + timedelta(hours=1),
|
||||
)
|
||||
callback = DifyHITLCallback(
|
||||
form_repository=repository,
|
||||
node_data=HumanInputNodeData(
|
||||
title="Approval",
|
||||
form_content="Please approve",
|
||||
inputs=[ParagraphInputConfig(output_variable_name="answer")],
|
||||
user_actions=[UserActionConfig(id="approve", title="Approve")],
|
||||
),
|
||||
)
|
||||
|
||||
decision = callback(_ctx("run-1", "node-1"))
|
||||
|
||||
assert decision.selected_handle == "approve"
|
||||
assert decision.inputs == {"answer": build_segment("yes")}
|
||||
assert decision.outputs == {
|
||||
"answer": build_segment("yes"),
|
||||
"__action_id": build_segment("approve"),
|
||||
"__action_value": build_segment("Approve"),
|
||||
"__rendered_content": build_segment("<p>Please approve</p>"),
|
||||
}
|
||||
|
||||
|
||||
def test_dify_hitl_callback_returns_timeout_for_explicit_timeout_form() -> None:
|
||||
repository = MagicMock(spec=HumanInputFormRepository)
|
||||
repository.get_form.return_value = SimpleNamespace(
|
||||
id="form-1",
|
||||
rendered_content="<p>Please approve</p>",
|
||||
selected_action_id=None,
|
||||
submitted_data=None,
|
||||
submitted=False,
|
||||
status=HumanInputFormStatus.TIMEOUT,
|
||||
created_at=naive_utc_now(),
|
||||
expiration_time=naive_utc_now() + timedelta(hours=1),
|
||||
)
|
||||
callback = DifyHITLCallback(
|
||||
form_repository=repository,
|
||||
node_data=HumanInputNodeData(title="Approval", form_content="Please approve"),
|
||||
)
|
||||
|
||||
decision = callback(_ctx("run-1", "node-1"))
|
||||
|
||||
assert decision.selected_handle == "__timeout__"
|
||||
assert decision.outputs == {
|
||||
"__action_id": build_segment(""),
|
||||
"__action_value": build_segment(""),
|
||||
"__rendered_content": build_segment("<p>Please approve</p>"),
|
||||
}
|
||||
|
||||
|
||||
def test_dify_hitl_callback_returns_timeout_for_waiting_form_past_node_deadline() -> None:
|
||||
repository = MagicMock(spec=HumanInputFormRepository)
|
||||
repository.get_form.return_value = SimpleNamespace(
|
||||
id="form-1",
|
||||
rendered_content="<p>Please approve</p>",
|
||||
selected_action_id=None,
|
||||
submitted_data=None,
|
||||
submitted=False,
|
||||
status=HumanInputFormStatus.WAITING,
|
||||
created_at=naive_utc_now(),
|
||||
expiration_time=naive_utc_now() - timedelta(minutes=1),
|
||||
)
|
||||
callback = DifyHITLCallback(
|
||||
form_repository=repository,
|
||||
node_data=HumanInputNodeData(title="Approval", form_content="Please approve"),
|
||||
)
|
||||
|
||||
decision = callback(_ctx("run-1", "node-1"))
|
||||
|
||||
assert decision.selected_handle == "__timeout__"
|
||||
assert decision.outputs == {
|
||||
"__action_id": build_segment(""),
|
||||
"__action_value": build_segment(""),
|
||||
"__rendered_content": build_segment("<p>Please approve</p>"),
|
||||
}
|
||||
|
||||
|
||||
def test_dify_hitl_callback_rejects_expired_form_as_invalid_resume_state() -> None:
|
||||
repository = MagicMock(spec=HumanInputFormRepository)
|
||||
repository.get_form.return_value = SimpleNamespace(
|
||||
id="form-1",
|
||||
rendered_content="<p>Please approve</p>",
|
||||
selected_action_id=None,
|
||||
submitted_data=None,
|
||||
submitted=False,
|
||||
status=HumanInputFormStatus.EXPIRED,
|
||||
created_at=naive_utc_now() - timedelta(days=8),
|
||||
expiration_time=naive_utc_now() + timedelta(hours=1),
|
||||
)
|
||||
callback = DifyHITLCallback(
|
||||
form_repository=repository,
|
||||
node_data=HumanInputNodeData(title="Approval", form_content="Please approve"),
|
||||
)
|
||||
|
||||
with pytest.raises(AssertionError, match="globally expired human input form"):
|
||||
callback(_ctx("run-1", "node-1"))
|
||||
|
||||
|
||||
def test_dify_hitl_callback_rejects_waiting_form_past_global_deadline_as_invalid_resume_state() -> None:
|
||||
repository = MagicMock(spec=HumanInputFormRepository)
|
||||
repository.get_form.return_value = SimpleNamespace(
|
||||
id="form-1",
|
||||
rendered_content="<p>Please approve</p>",
|
||||
selected_action_id=None,
|
||||
submitted_data=None,
|
||||
submitted=False,
|
||||
status=HumanInputFormStatus.WAITING,
|
||||
created_at=naive_utc_now() - timedelta(days=8),
|
||||
expiration_time=naive_utc_now() + timedelta(hours=1),
|
||||
)
|
||||
callback = DifyHITLCallback(
|
||||
form_repository=repository,
|
||||
node_data=HumanInputNodeData(title="Approval", form_content="Please approve"),
|
||||
)
|
||||
|
||||
with pytest.raises(AssertionError, match="global timeout"):
|
||||
callback(_ctx("run-1", "node-1"))
|
||||
@@ -6,8 +6,8 @@ from core.workflow.human_input_policy import (
|
||||
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 core.workflow.nodes.human_input.entities import SelectInputConfig, StringListSource
|
||||
from core.workflow.nodes.human_input.enums import ValueSourceType
|
||||
from graphon.runtime import VariablePool
|
||||
from models.human_input import RecipientType
|
||||
|
||||
|
||||
@@ -602,9 +602,7 @@ class TestDifyNodeFactoryCreateNode:
|
||||
)
|
||||
|
||||
if constructor_name == "HumanInputNode":
|
||||
form_repository = sentinel.form_repository
|
||||
factory._human_input_runtime = MagicMock()
|
||||
factory._human_input_runtime.build_form_repository.return_value = form_repository
|
||||
factory._build_human_input_callback = MagicMock(return_value=sentinel.hitl_callback)
|
||||
|
||||
node_config = {"id": "node-id", "data": {"type": node_type}}
|
||||
result = factory.create_node(node_config)
|
||||
@@ -630,11 +628,8 @@ class TestDifyNodeFactoryCreateNode:
|
||||
assert kwargs["file_reference_factory"] is sentinel.file_reference_factory
|
||||
factory._bound_tool_file_manager_factory.assert_not_called()
|
||||
elif constructor_name == "HumanInputNode":
|
||||
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()
|
||||
assert kwargs["hitl_callback"] is sentinel.hitl_callback
|
||||
factory._build_human_input_callback.assert_called_once()
|
||||
elif constructor_name == "ToolNode":
|
||||
assert kwargs["tool_file_manager"] is sentinel.tool_file_manager
|
||||
assert kwargs["runtime"] is sentinel.tool_runtime
|
||||
@@ -643,16 +638,14 @@ 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(
|
||||
def test_human_input_node_receives_built_hitl_callback(
|
||||
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
|
||||
factory._build_human_input_callback = MagicMock(return_value=sentinel.hitl_callback)
|
||||
monkeypatch.setattr(
|
||||
factory,
|
||||
"_resolve_node_class",
|
||||
@@ -663,10 +656,8 @@ class TestDifyNodeFactoryCreateNode:
|
||||
|
||||
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()
|
||||
assert kwargs["hitl_callback"] is sentinel.hitl_callback
|
||||
factory._build_human_input_callback.assert_called_once()
|
||||
|
||||
def test_tool_node_receives_tool_file_manager(self, monkeypatch: pytest.MonkeyPatch, factory) -> None:
|
||||
created_node = object()
|
||||
|
||||
@@ -34,13 +34,13 @@ from core.workflow.node_runtime import (
|
||||
build_dify_llm_file_saver,
|
||||
resolve_dify_run_context,
|
||||
)
|
||||
from core.workflow.nodes.human_input.entities import FileInputConfig, FileListInputConfig, HumanInputNodeData
|
||||
from graphon.file import File, FileTransferMethod, FileType
|
||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||
from graphon.model_runtime.entities.llm_entities import LLMPollingResult, LLMPollingStatus
|
||||
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage
|
||||
from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelFeature, ModelType
|
||||
from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel
|
||||
from graphon.nodes.human_input.entities import FileInputConfig, FileListInputConfig, HumanInputNodeData
|
||||
from graphon.nodes.llm.runtime_protocols import LLMPollingCapableProtocol
|
||||
from graphon.nodes.tool.entities import ToolNodeData, ToolProviderType
|
||||
from graphon.variables.segments import ArrayFileSegment, FileSegment
|
||||
|
||||
@@ -4,8 +4,8 @@ from dataclasses import dataclass, field
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from graphon.nodes.human_input.entities import FormInputConfig
|
||||
from graphon.nodes.human_input.enums import TimeoutUnit
|
||||
from core.workflow.nodes.human_input.entities import FormInputConfig
|
||||
from core.workflow.nodes.human_input.enums import TimeoutUnit
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
|
||||
|
||||
|
||||
@@ -6,11 +6,11 @@ from datetime import timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from graphon.nodes.human_input.entities import (
|
||||
from core.workflow.nodes.human_input.entities import (
|
||||
ParagraphInputConfig,
|
||||
UserActionConfig,
|
||||
)
|
||||
from graphon.nodes.human_input.enums import (
|
||||
from core.workflow.nodes.human_input.enums import (
|
||||
TimeoutUnit,
|
||||
)
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
|
||||
@@ -6,11 +6,11 @@ from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from graphon.nodes.human_input.entities import (
|
||||
from core.workflow.nodes.human_input.entities import (
|
||||
ParagraphInputConfig,
|
||||
UserActionConfig,
|
||||
)
|
||||
from graphon.nodes.human_input.enums import (
|
||||
from core.workflow.nodes.human_input.enums import (
|
||||
TimeoutUnit,
|
||||
)
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
|
||||
@@ -3,10 +3,16 @@ from __future__ import annotations
|
||||
from datetime import UTC, datetime
|
||||
from types import SimpleNamespace
|
||||
|
||||
from graphon.nodes.human_input.entities import FormDefinition, ParagraphInputConfig, UserActionConfig
|
||||
from graphon.nodes.human_input.enums import FormInputType
|
||||
from core.workflow.nodes.human_input.entities import FormDefinition, ParagraphInputConfig, UserActionConfig
|
||||
from core.workflow.nodes.human_input.enums import FormInputType
|
||||
from core.workflow.nodes.human_input.pause_reason import HumanInputRequired
|
||||
from graphon.entities.pause_reason import HitlRequired, PauseReasonType
|
||||
from models.human_input import RecipientType
|
||||
from repositories.sqlalchemy_api_workflow_run_repository import _build_human_input_required_reason
|
||||
from models.workflow import WorkflowPauseReason
|
||||
from repositories.sqlalchemy_api_workflow_run_repository import (
|
||||
_build_human_input_required_reason,
|
||||
_PrivateWorkflowPauseEntity,
|
||||
)
|
||||
|
||||
|
||||
def _build_form_model() -> SimpleNamespace:
|
||||
@@ -25,6 +31,7 @@ def _build_form_model() -> SimpleNamespace:
|
||||
id="form-1",
|
||||
node_id="node-1",
|
||||
form_definition=definition.model_dump_json(),
|
||||
rendered_content="rendered",
|
||||
expiration_time=expiration_time,
|
||||
)
|
||||
|
||||
@@ -45,6 +52,7 @@ def test_build_human_input_required_reason_prefers_standalone_web_app_token() ->
|
||||
)
|
||||
|
||||
assert reason.node_title == "Ask Name"
|
||||
assert reason.form_content == "rendered"
|
||||
assert reason.resolved_default_values == {"name": "Alice"}
|
||||
assert not hasattr(reason, "form_token")
|
||||
|
||||
@@ -62,3 +70,65 @@ def test_build_human_input_required_reason_falls_back_to_console_token() -> None
|
||||
assert reason.node_id == "node-1"
|
||||
assert reason.actions[0].id == "approve"
|
||||
assert not hasattr(reason, "form_token")
|
||||
|
||||
|
||||
def test_workflow_pause_reason_from_entity_persists_hitl_type_for_dify_human_input() -> None:
|
||||
reason_model = WorkflowPauseReason.from_entity(
|
||||
pause_id="pause-1",
|
||||
pause_reason=HumanInputRequired(
|
||||
form_id="form-1",
|
||||
form_content="content",
|
||||
inputs=[],
|
||||
actions=[],
|
||||
node_id="node-1",
|
||||
node_title="Ask Name",
|
||||
),
|
||||
)
|
||||
|
||||
assert reason_model.type_ == PauseReasonType.HITL_REQUIRED
|
||||
assert reason_model.form_id == "form-1"
|
||||
assert reason_model.node_id == "node-1"
|
||||
|
||||
|
||||
def test_workflow_pause_reason_to_entity_restores_graphon_hitl_reason() -> None:
|
||||
reason_model = WorkflowPauseReason(
|
||||
pause_id="pause-1",
|
||||
type_=PauseReasonType.HITL_REQUIRED,
|
||||
form_id="form-1",
|
||||
node_id="node-1",
|
||||
)
|
||||
|
||||
reason = reason_model.to_entity()
|
||||
|
||||
assert isinstance(reason, HitlRequired)
|
||||
assert reason.TYPE == PauseReasonType.HITL_REQUIRED
|
||||
assert reason.session_id == "form-1"
|
||||
assert reason.node_id == "node-1"
|
||||
|
||||
|
||||
def test_private_workflow_pause_entity_preserves_list_shaped_pause_reasons() -> None:
|
||||
pause_reasons = [
|
||||
HumanInputRequired(
|
||||
form_id="form-1",
|
||||
form_content="content",
|
||||
inputs=[],
|
||||
actions=[],
|
||||
node_id="node-1",
|
||||
node_title="Ask Name",
|
||||
)
|
||||
]
|
||||
entity = _PrivateWorkflowPauseEntity(
|
||||
pause_model=SimpleNamespace(
|
||||
id="pause-1",
|
||||
workflow_run_id="run-1",
|
||||
resumed_at=None,
|
||||
created_at=datetime(2024, 1, 1, tzinfo=UTC),
|
||||
),
|
||||
reason_models=[],
|
||||
pause_reasons=pause_reasons,
|
||||
)
|
||||
|
||||
result = entity.get_pause_reasons()
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert result == pause_reasons
|
||||
|
||||
+15
-6
@@ -6,8 +6,12 @@ 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 core.workflow.nodes.human_input.entities import (
|
||||
FormDefinition,
|
||||
ParagraphInputConfig,
|
||||
UserActionConfig,
|
||||
)
|
||||
from core.workflow.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
|
||||
@@ -17,11 +21,15 @@ from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchem
|
||||
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"}
|
||||
rendered_content_template = "Decision: {{#$output.decision#}}, Comment: {{#$output.comment#}}"
|
||||
form_definition = FormDefinition(
|
||||
form_content="content",
|
||||
inputs=[],
|
||||
form_content=rendered_content_template,
|
||||
inputs=[
|
||||
ParagraphInputConfig(output_variable_name="decision"),
|
||||
ParagraphInputConfig(output_variable_name="comment"),
|
||||
],
|
||||
user_actions=[UserActionConfig(id="approve", title="Approve")],
|
||||
rendered_content="Rendered Approve",
|
||||
rendered_content=rendered_content_template,
|
||||
expiration_time=expiration_time,
|
||||
node_title="Approval",
|
||||
display_in_ui=True,
|
||||
@@ -32,7 +40,7 @@ def test_map_human_input_content_populates_submission_data_from_stored_form_subm
|
||||
workflow_run_id="workflow-run-1",
|
||||
node_id="node-1",
|
||||
form_definition=form_definition.model_dump_json(),
|
||||
rendered_content="Rendered Approve",
|
||||
rendered_content=rendered_content_template,
|
||||
expiration_time=expiration_time,
|
||||
selected_action_id="approve",
|
||||
submitted_data=json.dumps(stored_submission_data),
|
||||
@@ -54,6 +62,7 @@ def test_map_human_input_content_populates_submission_data_from_stored_form_subm
|
||||
assert content is not None
|
||||
assert content.form_submission_data is not None
|
||||
assert content.form_submission_data.submitted_data == stored_submission_data
|
||||
assert content.form_submission_data.rendered_content == "Decision: approve, Comment: Looks good"
|
||||
|
||||
|
||||
def test_map_human_input_content_keeps_waiting_form_without_selected_action() -> None:
|
||||
|
||||
@@ -10,8 +10,8 @@ from sqlalchemy.orm import sessionmaker
|
||||
|
||||
import models.account as account_module
|
||||
import services.human_input_file_upload_service as service_module
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
|
||||
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
|
||||
|
||||
@@ -14,8 +14,7 @@ from core.repositories.human_input_repository import (
|
||||
HumanInputFormRecord,
|
||||
HumanInputFormSubmissionRepository,
|
||||
)
|
||||
from graphon.file import File, FileTransferMethod, FileType
|
||||
from graphon.nodes.human_input.entities import (
|
||||
from core.workflow.nodes.human_input.entities import (
|
||||
FileInputConfig,
|
||||
FileListInputConfig,
|
||||
FormDefinition,
|
||||
@@ -24,7 +23,8 @@ from graphon.nodes.human_input.entities import (
|
||||
StringListSource,
|
||||
UserActionConfig,
|
||||
)
|
||||
from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus, ValueSourceType
|
||||
from graphon.file import File, FileTransferMethod, FileType
|
||||
from graphon.runtime import GraphRuntimeState, VariablePool
|
||||
from libs.datetime_utils import naive_utc_now
|
||||
from models.human_input import RecipientType
|
||||
|
||||
@@ -2833,6 +2833,8 @@ class TestWorkflowServiceHumanInputOperations:
|
||||
workflow = MagicMock()
|
||||
workflow.environment_variables = []
|
||||
workflow.graph_dict = {}
|
||||
node_data = MagicMock()
|
||||
node_data.extract_variable_selector_to_variable_mapping.return_value = {}
|
||||
|
||||
with (
|
||||
patch("services.workflow_service.db"),
|
||||
@@ -2840,12 +2842,16 @@ class TestWorkflowServiceHumanInputOperations:
|
||||
patch("services.workflow_service.WorkflowDraftVariableService"),
|
||||
patch("services.workflow_service.VariablePool") as mock_pool_cls,
|
||||
patch("services.workflow_service.DraftVarLoader"),
|
||||
patch("services.workflow_service.HumanInputNode.extract_variable_selector_to_variable_mapping"),
|
||||
patch("services.workflow_service.HumanInputNodeData.model_validate", return_value=node_data),
|
||||
patch("services.workflow_service.load_into_variable_pool"),
|
||||
patch("services.workflow_service.WorkflowEntry.mapping_user_inputs_to_variable_pool"),
|
||||
):
|
||||
service._build_human_input_variable_pool(
|
||||
app_model=MagicMock(), workflow=workflow, node_config={}, manual_inputs={}, user_id="user-1"
|
||||
app_model=MagicMock(),
|
||||
workflow=workflow,
|
||||
node_config={"id": "node-1", "data": {}},
|
||||
manual_inputs={},
|
||||
user_id="user-1",
|
||||
)
|
||||
mock_pool_cls.assert_called_once()
|
||||
|
||||
@@ -2896,9 +2902,7 @@ class TestWorkflowServiceFreeNodeExecution:
|
||||
service.validate_features_structure(app, {})
|
||||
|
||||
def test_validate_human_input_node_data_error(self, service: WorkflowService) -> None:
|
||||
with patch(
|
||||
"graphon.nodes.human_input.entities.HumanInputNodeData.model_validate", side_effect=Exception("error")
|
||||
):
|
||||
with patch("services.workflow_service.HumanInputNodeData.model_validate", side_effect=Exception("error")):
|
||||
with pytest.raises(ValueError, match="Invalid HumanInput node data"):
|
||||
service._validate_human_input_node_data({})
|
||||
|
||||
@@ -2910,49 +2914,24 @@ class TestWorkflowServiceFreeNodeExecution:
|
||||
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"
|
||||
workflow.app_id = "app-1"
|
||||
account = MagicMock()
|
||||
account.id = "u-1"
|
||||
node_config = {"id": "n-1", "data": {"type": BuiltinNodeTypes.HUMAN_INPUT, "title": "Human Input"}}
|
||||
variable_pool = MagicMock()
|
||||
node_data = MagicMock()
|
||||
node_data.title = "Human Input"
|
||||
|
||||
with (
|
||||
patch("services.workflow_service.DifyGraphInitContext") as mock_graph_init_context_cls,
|
||||
patch("services.workflow_service.GraphRuntimeState"),
|
||||
patch(
|
||||
"services.workflow_service.adapt_human_input_node_data_for_graph",
|
||||
return_value=sentinel.adapted_node_data,
|
||||
) as mock_adapt_node_data,
|
||||
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,
|
||||
patch("services.workflow_service.HumanInputNodeData.model_validate", return_value=node_data),
|
||||
):
|
||||
mock_node_cls.validate_node_data.return_value = sentinel.node_data
|
||||
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
|
||||
mock_node_cls.assert_called_once()
|
||||
mock_graph_init_context_cls.assert_called_once_with(
|
||||
workflow_id="wf-1",
|
||||
graph_config=workflow.graph_dict,
|
||||
run_context=mock_build_dify_run_context.return_value,
|
||||
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_build_dify_run_context.return_value)
|
||||
mock_node_cls.assert_called_once_with(
|
||||
node_id="n-1",
|
||||
data=sentinel.node_data,
|
||||
graph_init_params=mock_graph_init_context_cls.return_value.to_graph_init_params.return_value,
|
||||
graph_runtime_state=ANY,
|
||||
file_reference_factory=mock_file_reference_factory_cls.return_value,
|
||||
runtime=mock_runtime_cls.return_value,
|
||||
)
|
||||
assert node.node_id == "n-1"
|
||||
assert node.title == "Human Input"
|
||||
assert node.node_data is node_data
|
||||
assert node.variable_pool is variable_pool
|
||||
|
||||
@@ -17,10 +17,10 @@ from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerat
|
||||
from core.app.entities.task_entities import StreamEvent
|
||||
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper
|
||||
from core.workflow.human_input_policy import FormDisposition, HumanInputSurface
|
||||
from graphon.entities.pause_reason import HumanInputRequired
|
||||
from core.workflow.nodes.human_input.entities import SelectInputConfig, StringListSource
|
||||
from core.workflow.nodes.human_input.enums import ValueSourceType
|
||||
from core.workflow.nodes.human_input.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.human_input import RecipientType
|
||||
|
||||
@@ -12,8 +12,8 @@ from core.workflow.human_input_adapter import (
|
||||
ExternalRecipient,
|
||||
MemberRecipient,
|
||||
)
|
||||
from core.workflow.nodes.human_input.entities import HumanInputNodeData
|
||||
from graphon.enums import BuiltinNodeTypes
|
||||
from graphon.nodes.human_input.entities import HumanInputNodeData
|
||||
from services import workflow_service as workflow_service_module
|
||||
from services.workflow_service import WorkflowService
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from graphon.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
|
||||
from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
|
||||
from tasks import human_input_timeout_tasks as task_module
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user