WIP: api debugging

This commit is contained in:
QuantumGhost
2025-11-26 00:33:44 +08:00
parent f368155995
commit dddcf1de6c
31 changed files with 847 additions and 55 deletions

View File

@ -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

View 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"

View File

@ -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

View File

@ -34,6 +34,7 @@ class _InMemoryFormRecipient(HumanInputFormRecipientEntity):
@dataclass
class _InMemoryFormEntity(HumanInputFormEntity):
form_id: str
rendered: str
token: str | None = None
@property
@ -48,6 +49,10 @@ class _InMemoryFormEntity(HumanInputFormEntity):
def recipients(self) -> list[HumanInputFormRecipientEntity]:
return []
@property
def rendered_content(self) -> str:
return self.rendered
class _InMemoryFormSubmission(FormSubmission):
def __init__(self, selected_action_id: str, form_data: Mapping[str, Any]) -> None:
@ -76,7 +81,7 @@ class InMemoryHumanInputFormRepository(HumanInputFormRepository):
self.created_params.append(params)
self._form_counter += 1
form_id = f"form-{self._form_counter}"
entity = _InMemoryFormEntity(form_id=form_id, token=f"token-{form_id}")
entity = _InMemoryFormEntity(form_id=form_id, rendered=params.rendered_content, token=f"token-{form_id}")
self.created_forms.append(entity)
self._forms_by_key[(params.workflow_execution_id, params.node_id)] = entity
return entity

View File

@ -248,6 +248,7 @@ def test_human_input_llm_streaming_across_multiple_branches() -> None:
mock_form_entity.id = "test_form_id"
mock_form_entity.web_app_token = "test_web_app_token"
mock_form_entity.recipients = []
mock_form_entity.rendered_content = "rendered"
mock_create_repo.create_form.return_value = mock_form_entity
def initial_graph_factory() -> tuple[Graph, GraphRuntimeState]:

View File

@ -193,6 +193,7 @@ def test_human_input_llm_streaming_order_across_pause() -> None:
mock_form_entity.id = "test_form_id"
mock_form_entity.web_app_token = "test_web_app_token"
mock_form_entity.recipients = []
mock_form_entity.rendered_content = "rendered"
mock_create_repo.create_form.return_value = mock_form_entity
def graph_factory() -> tuple[Graph, GraphRuntimeState]:

View File

@ -52,6 +52,7 @@ def _mock_form_repository_with_submission(action_id: str) -> HumanInputFormRepos
form_entity.id = "test-form-id"
form_entity.web_app_token = "test-form-token"
form_entity.recipients = []
form_entity.rendered_content = "rendered"
repo.get_form.return_value = form_entity
return repo
@ -63,6 +64,7 @@ def _mock_form_repository_without_submission() -> HumanInputFormRepository:
form_entity.id = "test-form-id"
form_entity.web_app_token = "test-form-token"
form_entity.recipients = []
form_entity.rendered_content = "rendered"
repo.create_form.return_value = form_entity
repo.get_form.return_value = None
return repo

View File

@ -0,0 +1,38 @@
from __future__ import annotations
import json
import uuid
from unittest.mock import MagicMock
import pytest
from tasks.app_generate.workflow_execute_task import _publish_streaming_response
@pytest.fixture
def mock_topic(mocker) -> MagicMock:
topic = MagicMock()
mocker.patch(
"tasks.app_generate.workflow_execute_task.AdvancedChatAppGenerator.get_response_topic",
return_value=topic,
)
return topic
def test_publish_streaming_response_with_uuid(mock_topic: MagicMock):
workflow_run_id = uuid.uuid4()
response_stream = iter([{"event": "foo"}, "ping"])
_publish_streaming_response(response_stream, workflow_run_id)
payloads = [call.args[0] for call in mock_topic.publish.call_args_list]
assert payloads == [json.dumps({"event": "foo"}).encode(), json.dumps("ping").encode()]
def test_publish_streaming_response_coerces_string_uuid(mock_topic: MagicMock):
workflow_run_id = uuid.uuid4()
response_stream = iter([{"event": "bar"}])
_publish_streaming_response(response_stream, str(workflow_run_id))
mock_topic.publish.assert_called_once_with(json.dumps({"event": "bar"}).encode())