mirror of
https://github.com/langgenius/dify.git
synced 2026-05-05 18:08:07 +08:00
WIP: api debugging
This commit is contained in:
@ -0,0 +1,139 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig
|
||||
from core.app.apps import message_based_app_generator
|
||||
from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator
|
||||
from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom
|
||||
from core.app.task_pipeline import message_cycle_manager
|
||||
from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
|
||||
from models.model import AppMode, Conversation, Message
|
||||
|
||||
|
||||
def _make_app_config() -> WorkflowUIBasedAppConfig:
|
||||
return WorkflowUIBasedAppConfig(
|
||||
tenant_id="tenant-id",
|
||||
app_id="app-id",
|
||||
app_mode=AppMode.ADVANCED_CHAT,
|
||||
workflow_id="workflow-id",
|
||||
additional_features=AppAdditionalFeatures(),
|
||||
variables=[],
|
||||
)
|
||||
|
||||
|
||||
def _make_generate_entity(app_config: WorkflowUIBasedAppConfig) -> AdvancedChatAppGenerateEntity:
|
||||
return AdvancedChatAppGenerateEntity(
|
||||
task_id="task-id",
|
||||
app_config=app_config,
|
||||
file_upload_config=None,
|
||||
conversation_id=None,
|
||||
inputs={},
|
||||
query="hello",
|
||||
files=[],
|
||||
parent_message_id=None,
|
||||
user_id="user-id",
|
||||
stream=True,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
extras={},
|
||||
workflow_run_id="workflow-run-id",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _mock_db_session(monkeypatch):
|
||||
session = MagicMock()
|
||||
|
||||
def refresh_side_effect(obj):
|
||||
if isinstance(obj, Conversation) and obj.id is None:
|
||||
obj.id = "generated-conversation-id"
|
||||
if isinstance(obj, Message) and obj.id is None:
|
||||
obj.id = "generated-message-id"
|
||||
|
||||
session.refresh.side_effect = refresh_side_effect
|
||||
session.add.return_value = None
|
||||
session.commit.return_value = None
|
||||
|
||||
monkeypatch.setattr(message_based_app_generator, "db", SimpleNamespace(session=session))
|
||||
return session
|
||||
|
||||
|
||||
def test_init_generate_records_sets_conversation_metadata():
|
||||
app_config = _make_app_config()
|
||||
entity = _make_generate_entity(app_config)
|
||||
|
||||
generator = AdvancedChatAppGenerator()
|
||||
|
||||
conversation, _ = generator._init_generate_records(entity, conversation=None)
|
||||
|
||||
assert entity.conversation_id == "generated-conversation-id"
|
||||
assert conversation.id == "generated-conversation-id"
|
||||
assert entity.is_new_conversation is True
|
||||
|
||||
|
||||
def test_init_generate_records_marks_existing_conversation():
|
||||
app_config = _make_app_config()
|
||||
entity = _make_generate_entity(app_config)
|
||||
|
||||
existing_conversation = Conversation(
|
||||
app_id=app_config.app_id,
|
||||
app_model_config_id=None,
|
||||
model_provider=None,
|
||||
override_model_configs=None,
|
||||
model_id=None,
|
||||
mode=app_config.app_mode.value,
|
||||
name="existing",
|
||||
inputs={},
|
||||
introduction="",
|
||||
system_instruction="",
|
||||
system_instruction_tokens=0,
|
||||
status="normal",
|
||||
invoke_from=InvokeFrom.WEB_APP.value,
|
||||
from_source="api",
|
||||
from_end_user_id="user-id",
|
||||
from_account_id=None,
|
||||
)
|
||||
existing_conversation.id = "existing-conversation-id"
|
||||
|
||||
generator = AdvancedChatAppGenerator()
|
||||
|
||||
conversation, _ = generator._init_generate_records(entity, conversation=existing_conversation)
|
||||
|
||||
assert entity.conversation_id == "existing-conversation-id"
|
||||
assert conversation is existing_conversation
|
||||
assert entity.is_new_conversation is False
|
||||
|
||||
|
||||
def test_message_cycle_manager_uses_new_conversation_flag(monkeypatch):
|
||||
app_config = _make_app_config()
|
||||
entity = _make_generate_entity(app_config)
|
||||
entity.conversation_id = "existing-conversation-id"
|
||||
entity.is_new_conversation = True
|
||||
entity.extras = {"auto_generate_conversation_name": True}
|
||||
|
||||
captured = {}
|
||||
|
||||
class DummyThread:
|
||||
def __init__(self, **kwargs):
|
||||
self.kwargs = kwargs
|
||||
self.started = False
|
||||
|
||||
def start(self):
|
||||
self.started = True
|
||||
|
||||
def fake_thread(**kwargs):
|
||||
thread = DummyThread(**kwargs)
|
||||
captured["thread"] = thread
|
||||
return thread
|
||||
|
||||
monkeypatch.setattr(message_cycle_manager, "Thread", fake_thread)
|
||||
|
||||
manager = MessageCycleManager(application_generate_entity=entity, task_state=MagicMock())
|
||||
thread = manager.generate_conversation_name(conversation_id="existing-conversation-id", query="hello")
|
||||
|
||||
assert thread is captured["thread"]
|
||||
assert thread.started is True
|
||||
assert entity.is_new_conversation is False
|
||||
148
api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py
Normal file
148
api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py
Normal file
@ -0,0 +1,148 @@
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter
|
||||
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.entities.pause_reason import HumanInputRequired
|
||||
from core.workflow.graph_events.graph import GraphRunPausedEvent
|
||||
from core.workflow.nodes.human_input.entities import FormInput, FormInputType, UserAction
|
||||
from core.workflow.system_variable import SystemVariable
|
||||
from models.account import Account
|
||||
|
||||
|
||||
class _RecordingWorkflowAppRunner(WorkflowAppRunner):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.published_events = []
|
||||
|
||||
def _publish_event(self, event):
|
||||
self.published_events.append(event)
|
||||
|
||||
|
||||
class _FakeRuntimeState:
|
||||
def get_paused_nodes(self):
|
||||
return ["node-pause-1"]
|
||||
|
||||
|
||||
def _build_runner():
|
||||
app_entity = SimpleNamespace(
|
||||
app_config=SimpleNamespace(app_id="app-id"),
|
||||
inputs={},
|
||||
files=[],
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
single_iteration_run=None,
|
||||
single_loop_run=None,
|
||||
workflow_execution_id="run-id",
|
||||
user_id="user-id",
|
||||
)
|
||||
workflow = SimpleNamespace(
|
||||
graph_dict={},
|
||||
tenant_id="tenant-id",
|
||||
environment_variables={},
|
||||
id="workflow-id",
|
||||
)
|
||||
queue_manager = SimpleNamespace(publish=lambda event, pub_from: None)
|
||||
return _RecordingWorkflowAppRunner(
|
||||
application_generate_entity=app_entity,
|
||||
queue_manager=queue_manager,
|
||||
variable_loader=MagicMock(),
|
||||
workflow=workflow,
|
||||
system_user_id="sys-user",
|
||||
root_node_id=None,
|
||||
workflow_execution_repository=MagicMock(),
|
||||
workflow_node_execution_repository=MagicMock(),
|
||||
graph_engine_layers=(),
|
||||
graph_runtime_state=None,
|
||||
)
|
||||
|
||||
|
||||
def test_graph_run_paused_event_emits_queue_pause_event():
|
||||
runner = _build_runner()
|
||||
reason = HumanInputRequired(
|
||||
form_id="form-1",
|
||||
form_content="content",
|
||||
inputs=[],
|
||||
actions=[],
|
||||
node_id="node-human",
|
||||
node_title="Human Step",
|
||||
web_app_form_token="tok",
|
||||
)
|
||||
event = GraphRunPausedEvent(reasons=[reason], outputs={"foo": "bar"})
|
||||
workflow_entry = SimpleNamespace(
|
||||
graph_engine=SimpleNamespace(graph_runtime_state=_FakeRuntimeState()),
|
||||
)
|
||||
|
||||
runner._handle_event(workflow_entry, 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.outputs == {"foo": "bar"}
|
||||
assert queue_event.paused_nodes == ["node-pause-1"]
|
||||
|
||||
|
||||
def _build_converter():
|
||||
application_generate_entity = SimpleNamespace(
|
||||
inputs={},
|
||||
files=[],
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
app_config=SimpleNamespace(app_id="app-id", tenant_id="tenant-id"),
|
||||
)
|
||||
system_variables = SystemVariable(
|
||||
user_id="user",
|
||||
app_id="app-id",
|
||||
workflow_id="workflow-id",
|
||||
workflow_execution_id="run-id",
|
||||
)
|
||||
user = MagicMock(spec=Account)
|
||||
user.id = "account-id"
|
||||
user.name = "Tester"
|
||||
user.email = "tester@example.com"
|
||||
return WorkflowResponseConverter(
|
||||
application_generate_entity=application_generate_entity,
|
||||
user=user,
|
||||
system_variables=system_variables,
|
||||
)
|
||||
|
||||
|
||||
def test_queue_workflow_paused_event_to_stream_responses():
|
||||
converter = _build_converter()
|
||||
converter.workflow_start_to_stream_response(task_id="task", workflow_run_id="run-id", workflow_id="workflow-id")
|
||||
|
||||
reason = HumanInputRequired(
|
||||
form_id="form-1",
|
||||
form_content="Rendered",
|
||||
inputs=[
|
||||
FormInput(type=FormInputType.TEXT_INPUT, output_variable_name="field", placeholder=None),
|
||||
],
|
||||
actions=[UserAction(id="approve", title="Approve")],
|
||||
node_id="node-id",
|
||||
node_title="Human Step",
|
||||
web_app_form_token="token",
|
||||
)
|
||||
queue_event = QueueWorkflowPausedEvent(
|
||||
reasons=[reason],
|
||||
outputs={"answer": "value"},
|
||||
paused_nodes=["node-id"],
|
||||
)
|
||||
|
||||
responses = converter.workflow_pause_to_stream_response(event=queue_event, task_id="task")
|
||||
|
||||
assert isinstance(responses[-1], WorkflowPauseStreamResponse)
|
||||
pause_resp = responses[-1]
|
||||
assert pause_resp.workflow_run_id == "run-id"
|
||||
assert pause_resp.data.paused_nodes == ["node-id"]
|
||||
assert pause_resp.data.outputs == {"answer": "value"}
|
||||
assert pause_resp.data.reasons[0]["form_id"] == "form-1"
|
||||
|
||||
assert isinstance(responses[0], HumanInputRequiredResponse)
|
||||
hi_resp = responses[0]
|
||||
assert hi_resp.data.form_id == "form-1"
|
||||
assert hi_resp.data.node_id == "node-id"
|
||||
assert hi_resp.data.node_title == "Human Step"
|
||||
assert hi_resp.data.inputs[0].output_variable_name == "field"
|
||||
assert hi_resp.data.actions[0].id == "approve"
|
||||
@ -0,0 +1,141 @@
|
||||
import json
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
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.pause_state_persist_layer import (
|
||||
WorkflowResumptionContext,
|
||||
_AdvancedChatAppGenerateEntityWrapper,
|
||||
_WorkflowGenerateEntityWrapper,
|
||||
)
|
||||
from core.ops.ops_trace_manager import TraceQueueManager
|
||||
from models.model import AppMode
|
||||
|
||||
|
||||
class TraceQueueManagerStub(TraceQueueManager):
|
||||
"""Minimal TraceQueueManager stub that avoids Flask dependencies."""
|
||||
|
||||
def __init__(self):
|
||||
# Skip parent initialization to avoid starting timers or accessing Flask globals.
|
||||
pass
|
||||
|
||||
|
||||
def _build_workflow_app_config(app_mode: AppMode) -> WorkflowUIBasedAppConfig:
|
||||
return WorkflowUIBasedAppConfig(
|
||||
tenant_id="tenant-id",
|
||||
app_id="app-id",
|
||||
app_mode=app_mode,
|
||||
workflow_id=f"{app_mode.value}-workflow-id",
|
||||
)
|
||||
|
||||
|
||||
def _create_workflow_generate_entity(trace_manager: TraceQueueManager | None = None) -> WorkflowAppGenerateEntity:
|
||||
return WorkflowAppGenerateEntity(
|
||||
task_id="workflow-task",
|
||||
app_config=_build_workflow_app_config(AppMode.WORKFLOW),
|
||||
inputs={"topic": "serialization"},
|
||||
files=[],
|
||||
user_id="user-workflow",
|
||||
stream=True,
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
call_depth=1,
|
||||
trace_manager=trace_manager,
|
||||
workflow_execution_id="workflow-exec-id",
|
||||
extras={"external_trace_id": "trace-id"},
|
||||
)
|
||||
|
||||
|
||||
def _create_advanced_chat_generate_entity(trace_manager: TraceQueueManager | None = None) -> AdvancedChatAppGenerateEntity:
|
||||
return AdvancedChatAppGenerateEntity(
|
||||
task_id="advanced-task",
|
||||
app_config=_build_workflow_app_config(AppMode.ADVANCED_CHAT),
|
||||
conversation_id="conversation-id",
|
||||
inputs={"topic": "roundtrip"},
|
||||
files=[],
|
||||
user_id="user-advanced",
|
||||
stream=False,
|
||||
invoke_from=InvokeFrom.DEBUGGER,
|
||||
query="Explain serialization",
|
||||
extras={"auto_generate_conversation_name": True},
|
||||
trace_manager=trace_manager,
|
||||
workflow_run_id="workflow-run-id",
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_app_generate_entity_roundtrip_excludes_trace_manager():
|
||||
entity = _create_workflow_generate_entity(trace_manager=TraceQueueManagerStub())
|
||||
|
||||
serialized = entity.model_dump_json()
|
||||
payload = json.loads(serialized)
|
||||
|
||||
assert "trace_manager" not in payload
|
||||
|
||||
restored = WorkflowAppGenerateEntity.model_validate_json(serialized)
|
||||
|
||||
assert restored.model_dump() == entity.model_dump()
|
||||
assert restored.trace_manager is None
|
||||
|
||||
|
||||
def test_advanced_chat_generate_entity_roundtrip_excludes_trace_manager():
|
||||
entity = _create_advanced_chat_generate_entity(trace_manager=TraceQueueManagerStub())
|
||||
|
||||
serialized = entity.model_dump_json()
|
||||
payload = json.loads(serialized)
|
||||
|
||||
assert "trace_manager" not in payload
|
||||
|
||||
restored = AdvancedChatAppGenerateEntity.model_validate_json(serialized)
|
||||
|
||||
assert restored.model_dump() == entity.model_dump()
|
||||
assert restored.trace_manager is None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ResumptionContextCase:
|
||||
name: str
|
||||
context_factory: Callable[[], tuple[WorkflowResumptionContext, type]]
|
||||
|
||||
|
||||
def _workflow_resumption_case() -> tuple[WorkflowResumptionContext, type]:
|
||||
entity = _create_workflow_generate_entity(trace_manager=TraceQueueManagerStub())
|
||||
context = WorkflowResumptionContext(
|
||||
serialized_graph_runtime_state=json.dumps({"state": "workflow"}),
|
||||
generate_entity=_WorkflowGenerateEntityWrapper(entity=entity),
|
||||
)
|
||||
return context, WorkflowAppGenerateEntity
|
||||
|
||||
|
||||
def _advanced_chat_resumption_case() -> tuple[WorkflowResumptionContext, type]:
|
||||
entity = _create_advanced_chat_generate_entity(trace_manager=TraceQueueManagerStub())
|
||||
context = WorkflowResumptionContext(
|
||||
serialized_graph_runtime_state=json.dumps({"state": "advanced"}),
|
||||
generate_entity=_AdvancedChatAppGenerateEntityWrapper(entity=entity),
|
||||
)
|
||||
return context, AdvancedChatAppGenerateEntity
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"case",
|
||||
[
|
||||
pytest.param(ResumptionContextCase("workflow", _workflow_resumption_case), id="workflow"),
|
||||
pytest.param(ResumptionContextCase("advanced_chat", _advanced_chat_resumption_case), id="advanced_chat"),
|
||||
],
|
||||
)
|
||||
def test_workflow_resumption_context_roundtrip(case: ResumptionContextCase):
|
||||
context, expected_type = case.context_factory()
|
||||
|
||||
serialized = context.dumps()
|
||||
restored = WorkflowResumptionContext.loads(serialized)
|
||||
|
||||
assert restored.serialized_graph_runtime_state == context.serialized_graph_runtime_state
|
||||
entity = restored.get_generate_entity()
|
||||
assert isinstance(entity, expected_type)
|
||||
assert entity.model_dump() == context.get_generate_entity().model_dump()
|
||||
assert entity.trace_manager is None
|
||||
Reference in New Issue
Block a user