From 7bf77042072219b1d2f2a1de8e418ed92c53e223 Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Thu, 8 Oct 2026 18:34:54 +0800 Subject: [PATCH 01/11] test(api): migrate App and Agent runner coverage to service adapters --- .../core/agent/test_base_agent_runner.py | 132 ++-- .../core/agent/test_cot_agent_runner.py | 97 ++- .../core/agent/test_cot_chat_agent_runner.py | 14 +- .../agent/test_cot_completion_agent_runner.py | 4 +- .../core/agent/test_fc_agent_runner.py | 54 +- .../apps/advanced_chat/test_app_generator.py | 564 +++++++-------- .../test_app_runner_conversation_variables.py | 177 ++--- .../test_app_runner_input_moderation.py | 102 +-- .../test_generate_task_pipeline.py | 646 ++++++++---------- .../test_generate_task_pipeline_core.py | 165 +++-- .../app/apps/agent_app/test_app_generator.py | 171 +++-- .../app/apps/agent_app/test_app_runner.py | 260 ++++--- .../app/apps/agent_app/test_input_guards.py | 96 ++- .../agent_app/test_runtime_request_builder.py | 32 +- .../app/apps/agent_app/test_session_store.py | 3 +- .../test_agent_chat_app_generator.py | 81 +-- .../agent_chat/test_agent_chat_app_runner.py | 221 +++--- .../chat/test_app_generator_and_runner.py | 264 +++---- .../chat/test_base_app_runner_multimodal.py | 97 ++- .../test_workflow_response_converter.py | 2 +- ...workflow_response_converter_human_input.py | 18 +- ..._workflow_response_converter_resumption.py | 14 +- ..._workflow_response_converter_truncation.py | 113 ++- .../app/apps/completion/test_app_runner.py | 91 +-- ...est_completion_completion_app_generator.py | 44 +- .../pipeline/test_pipeline_config_manager.py | 4 +- ...st_pipeline_generate_response_converter.py | 4 +- .../pipeline/test_pipeline_queue_manager.py | 4 +- .../app/apps/pipeline/test_pipeline_runner.py | 64 +- .../apps/test_advanced_chat_app_generator.py | 87 ++- .../core/app/apps/test_base_app_generator.py | 98 +-- .../core/app/apps/test_base_app_runner.py | 219 +++--- .../test_legacy_stop_graphengine_lifecycle.py | 2 +- .../apps/test_message_based_app_generator.py | 56 +- .../core/app/apps/test_message_generator.py | 33 +- .../core/app/apps/test_pause_resume.py | 19 +- .../core/app/apps/test_streaming_utils.py | 14 +- .../app/apps/test_workflow_app_generator.py | 125 +--- .../app/apps/test_workflow_app_runner_core.py | 153 +++-- .../test_workflow_app_runner_notifications.py | 13 +- .../test_workflow_app_runner_single_node.py | 55 +- .../app/apps/test_workflow_pause_events.py | 129 ++-- .../workflow/test_active_workflow_tasks.py | 6 +- .../apps/workflow/test_app_generator_extra.py | 246 +++---- .../apps/workflow/test_command_channels.py | 16 +- .../workflow/test_generate_task_pipeline.py | 187 +++-- .../test_generate_task_pipeline_core.py | 198 +++--- .../workflow/test_stop_aware_ready_queue.py | 14 +- .../test_based_generate_task_pipeline.py | 30 +- ...st_easy_ui_based_generate_task_pipeline.py | 20 +- ...sy_ui_based_generate_task_pipeline_core.py | 437 ++++-------- .../test_easy_ui_message_end_files.py | 42 +- ...test_message_cycle_manager_optimization.py | 164 +++-- .../app/workflow/layers/test_persistence.py | 10 +- .../test_persistence_inspector_publish.py | 4 +- .../core/app/workflow/test_file_runtime.py | 2 +- .../core/app/workflow/test_node_factory.py | 21 +- .../app/workflow/test_persistence_layer.py | 4 +- 58 files changed, 2828 insertions(+), 3114 deletions(-) diff --git a/api/tests/unit_tests/core/agent/test_base_agent_runner.py b/api/tests/unit_tests/core/agent/test_base_agent_runner.py index c6808ed39f62be..a5896b0e580340 100644 --- a/api/tests/unit_tests/core/agent/test_base_agent_runner.py +++ b/api/tests/unit_tests/core/agent/test_base_agent_runner.py @@ -6,8 +6,7 @@ from pytest_mock import MockerFixture from sqlalchemy.orm import Session -import core.agent.base_agent_runner as module -from core.agent.base_agent_runner import BaseAgentRunner +import services.agent.chat.base_runner as module from core.agent.entities import AgentEntity, AgentToolEntity from core.app.app_config.entities import ( AppAdditionalFeatures, @@ -34,15 +33,26 @@ ToolIdentity, ToolProviderType, ) -from core.tools.utils.dataset_retriever.dataset_retriever_base_tool import DatasetRetrieverBaseTool -from core.tools.utils.dataset_retriever_tool import DatasetRetrieverTool from extensions.ext_storage import storage from extensions.storage.storage_type import StorageType from graphon.file import FileTransferMethod, FileType from graphon.model_runtime.entities import LLMUsage, PromptMessageTool from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel from models.enums import ConversationFromSource, CreatorUserRole, MessageFileBelongsTo -from models.model import AppMode, AppModelConfig, Conversation, Message, MessageAgentThought, MessageFile, UploadFile +from models.model import ( + AppMode, + AppModelConfig, + Conversation, + Message, + MessageAgentThought, + MessageFile, + UploadFile, +) +from services.agent.chat.base_runner import BaseAgentRunner +from services.app.generation.ports import AgentHistoryMessage +from services.knowledge.retrieval.dataset_retrieval import DatasetRetrieval +from services.tools.dataset.tool import DatasetRetrieverTool +from tests.unit_tests.model_factories import make_app def _message( @@ -110,7 +120,10 @@ def _thought( def _persist_history(session: Session, *messages: Message, thoughts: list[MessageAgentThought] | None = None) -> None: - session.add_all([_conversation(), *messages, *(thoughts or [])]) + for message in messages: + message.created_at = datetime(2030, 1, 1) + session.merge(message) + session.add_all(thoughts or []) session.commit() @@ -188,24 +201,29 @@ def _tool_entity(name: str) -> ToolEntity: ) -def _dataset_tool(mocker: MockerFixture, name: str) -> DatasetRetrieverTool: +def _dataset_tool(mocker: MockerFixture, name: str, _sessions) -> DatasetRetrieverTool: return DatasetRetrieverTool( entity=_tool_entity(name), runtime=ToolRuntime(tenant_id="tenant"), - retrieval_tool=mocker.Mock(spec=DatasetRetrieverBaseTool), + retrieval=mocker.Mock(spec=DatasetRetrieval), + dataset_id="dataset", + config=DatasetRetrieveConfigEntity(retrieve_strategy="single"), + top_k=2, + inputs={}, + invoke_from=InvokeFrom.DEBUGGER, + return_resource=False, + hit_callback=mocker.Mock(), + app_id="app1", ) @pytest.fixture -def database_session(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> Session: - """Bind legacy global-session writes to a real SQLite Session.""" - - monkeypatch.setattr(module.db, "session", sqlite_session) +def database_session(sqlite_session: Session) -> Session: return sqlite_session @pytest.fixture -def runner(sqlite_session: Session, mocker: MockerFixture) -> BaseAgentRunner: +def runner(sqlite_session: Session, mocker: MockerFixture, workflow_runtime, *, app_records) -> BaseAgentRunner: app_config = _app_config() llm = mocker.Mock(spec=LargeLanguageModel) llm.get_model_schema.return_value = None @@ -216,8 +234,12 @@ def runner(sqlite_session: Session, mocker: MockerFixture) -> BaseAgentRunner: credentials={}, ) + sqlite_session.add_all([make_app(app_id="app1", tenant_id="tenant"), _conversation(), _message()]) + sqlite_session.commit() return BaseAgentRunner( - session=sqlite_session, + dataset_tools=workflow_runtime.dataset_tools, + records=app_records, + workflow_runtime=workflow_runtime, tenant_id="tenant", application_generate_entity=_app_generate(app_config=app_config), conversation=_conversation(), @@ -265,18 +287,18 @@ def test_create_agent_thought_persists_row( files: list[str], expected_files: str, ) -> None: - thought_id = runner.create_agent_thought("message-1", "message", "tool", "input", files) + thought_id = runner.create_agent_thought("msg_current", "message", "tool", "input", files) stored = database_session.get(MessageAgentThought, thought_id) assert stored is not None - assert stored.message_id == "message-1" + assert stored.message_id == "msg_current" assert stored.message_files == expected_files assert stored.position == 1 assert runner.agent_thought_count == 1 def _persist_thought(session: Session, *, tool: str = "tool1;tool2") -> MessageAgentThought: - thought = _thought(thought_id="thought-1", tool=tool, thought="") + thought = _thought(thought_id="thought-1", message_id="msg_current", tool=tool, thought="") session.add(thought) session.commit() return thought @@ -323,6 +345,7 @@ def test_save_agent_thought_full_update( usage, ) + database_session.expire_all() stored = database_session.get(MessageAgentThought, thought.id) assert stored is not None assert stored.answer == "answer" @@ -343,6 +366,7 @@ def test_save_agent_thought_uses_label_fallback( runner.save_agent_thought(thought.id, None, None, None, None, None, None, [], None) + database_session.expire_all() stored = database_session.get(MessageAgentThought, thought.id) assert stored is not None assert json.loads(stored.tool_labels_str)["unknown_tool"]["en_US"] == "unknown_tool" @@ -358,6 +382,7 @@ def test_save_agent_thought_preserves_existing_labels( runner.save_agent_thought(thought.id, None, None, None, None, None, None, [], None) + database_session.expire_all() stored = database_session.get(MessageAgentThought, thought.id) assert stored is not None assert json.loads(stored.tool_labels_str)["tool1"]["en_US"] == "existing" @@ -394,6 +419,7 @@ def dumps_side_effect(value, *args, **kwargs): None, ) + database_session.expire_all() stored = database_session.get(MessageAgentThought, thought.id) assert stored is not None assert isinstance(stored.tool_input, str) @@ -411,6 +437,7 @@ def test_save_agent_thought_accepts_empty_message_ids( thought = _persist_thought(database_session) runner.save_agent_thought(thought.id, None, None, None, None, "meta_string", None, messages_ids, None) # type: ignore[arg-type] + database_session.expire_all() stored = database_session.get(MessageAgentThought, thought.id) assert stored is not None assert stored.tool_meta_str == "meta_string" @@ -465,8 +492,24 @@ def _upload_file() -> UploadFile: ) +def _history_entry(session): + from sqlalchemy import select + + from models.model import load_annotation_reply_config + + message = _message(message_id="m1") + files = list(session.scalars(select(MessageFile).where(MessageFile.message_id == message.id))) + config = message.app_model_config_with_session(session=session) + return AgentHistoryMessage( + message=message, + thoughts=[], + files=files, + model_config=config.to_dict(annotation_reply=load_annotation_reply_config(session, "app1")) if config else None, + ) + + def test_organize_user_prompt_without_files(runner: BaseAgentRunner, sqlite_session: Session) -> None: - result = runner.organize_agent_user_prompt(_message(message_id="m1"), session=sqlite_session) + result = runner.organize_agent_user_prompt(_history_entry(sqlite_session)) assert result.content == "hello" @@ -474,7 +517,7 @@ def test_organize_user_prompt_with_files_but_no_config(runner: BaseAgentRunner, sqlite_session.add(_message_file()) sqlite_session.commit() - result = runner.organize_agent_user_prompt(_message(message_id="m1"), session=sqlite_session) + result = runner.organize_agent_user_prompt(_history_entry(sqlite_session)) assert result.content == "hello" @@ -484,12 +527,12 @@ def test_organize_user_prompt_uses_file_config( sqlite_session: Session, ) -> None: config = _file_enabled_app_model_config() - sqlite_session.add_all([config, _conversation(app_model_config_id=config.id), _message_file()]) + sqlite_session.merge(_conversation(app_model_config_id=config.id)) + sqlite_session.add_all([config, _message_file()]) sqlite_session.commit() result = runner.organize_agent_user_prompt( - _message(message_id="m1"), - session=sqlite_session, + _history_entry(sqlite_session), ) assert result.content == "hello" @@ -506,13 +549,13 @@ def test_organize_user_prompt_builds_file_content( belongs_to=MessageFileBelongsTo.USER, upload_file_id=upload_file.id, ) - sqlite_session.add_all([config, _conversation(app_model_config_id=config.id), upload_file, message_file]) + sqlite_session.merge(_conversation(app_model_config_id=config.id)) + sqlite_session.add_all([config, upload_file, message_file]) sqlite_session.commit() mocker.patch.object(storage, "load", return_value=b"image") result = runner.organize_agent_user_prompt( - _message(message_id="m1"), - session=sqlite_session, + _history_entry(sqlite_session), ) assert isinstance(result.content, list) @@ -520,21 +563,21 @@ def test_organize_user_prompt_builds_file_content( assert isinstance(result.content[-1], module.TextPromptMessageContent) -def test_organize_history_empty_preserves_system_prompt(runner: BaseAgentRunner, sqlite_session: Session) -> None: +def test_organize_history_empty_preserves_system_prompt(runner: BaseAgentRunner) -> None: system_message = module.SystemPromptMessage(content="sys") - result = runner.organize_agent_history([system_message], session=sqlite_session) + result = runner.organize_agent_history([system_message], history=[]) assert result == [system_message] def test_organize_history_with_answer_only(runner: BaseAgentRunner, sqlite_session: Session) -> None: _persist_history(sqlite_session, _message(message_id="m1", answer="answer")) - result = runner.organize_agent_history([], session=sqlite_session) + result = runner.organize_agent_history([], history=runner._records.agent_history(runner._identity)) assert any(isinstance(item, module.AssistantPromptMessage) and item.content == "answer" for item in result) def test_organize_history_skips_current_message(runner: BaseAgentRunner, sqlite_session: Session) -> None: _persist_history(sqlite_session, _message(message_id="msg_current", answer="answer")) - result = runner.organize_agent_history([], session=sqlite_session) + result = runner.organize_agent_history([], history=runner._records.agent_history(runner._identity)) assert result == [] @@ -564,7 +607,7 @@ def test_organize_history_reconstructs_tool_flows( ) _persist_history(sqlite_session, message, thoughts=[thought]) - result = runner.organize_agent_history([], session=sqlite_session) + result = runner.organize_agent_history([], history=runner._records.agent_history(runner._identity)) assert isinstance(result, list) assert any(isinstance(item, module.AssistantPromptMessage) for item in result) @@ -575,7 +618,7 @@ def test_organize_history_without_tool_name(runner: BaseAgentRunner, sqlite_sess thought = _thought(thought_id="thought-1", message_id=message.id, tool=None) _persist_history(sqlite_session, message, thoughts=[thought]) - result = runner.organize_agent_history([], session=sqlite_session) + result = runner.organize_agent_history([], history=runner._records.agent_history(runner._identity)) assert any(isinstance(item, module.AssistantPromptMessage) and item.content == "thinking" for item in result) @@ -597,8 +640,8 @@ def test_convert_tool_to_prompt_message_tool(runner: BaseAgentRunner, mocker: Mo assert prompt_tool.parameters == schema -def test_convert_dataset_retriever_tool(runner: BaseAgentRunner, mocker: MockerFixture) -> None: - dataset_tool = _dataset_tool(mocker, "ds") +def test_convert_dataset_retriever_tool(runner: BaseAgentRunner, mocker: MockerFixture, sqlite_session_factory) -> None: + dataset_tool = _dataset_tool(mocker, "ds", sqlite_session_factory) prompt = runner._convert_dataset_retriever_tool_to_prompt_message_tool(dataset_tool) @@ -606,12 +649,14 @@ def test_convert_dataset_retriever_tool(runner: BaseAgentRunner, mocker: MockerF assert prompt.parameters["required"] == ["query"] -def test_init_prompt_tools_adds_agent_and_dataset_tools(runner: BaseAgentRunner, mocker: MockerFixture) -> None: +def test_init_prompt_tools_adds_agent_and_dataset_tools( + runner: BaseAgentRunner, mocker: MockerFixture, sqlite_session_factory +) -> None: agent_tool = _agent_tool("agent_tool") agent_runtime = mocker.Mock(spec=Tool, entity=_tool_entity("agent_tool")) agent_runtime.get_llm_parameters_json_schema.return_value = {"type": "object", "properties": {}} mocker.patch.object(module.ToolManager, "get_agent_tool_runtime", return_value=agent_runtime) - dataset_tool = _dataset_tool(mocker, "dataset_tool") + dataset_tool = _dataset_tool(mocker, "dataset_tool", sqlite_session_factory) runner.app_config.agent = _agent(agent_tool) runner.dataset_tools = [dataset_tool] @@ -633,8 +678,7 @@ def test_init_prompt_tools_skips_deleted_agent_tool(runner: BaseAgentRunner, moc def test_init_uses_real_session_for_count_and_dependencies( - sqlite_session: Session, - mocker: MockerFixture, + sqlite_session: Session, mocker: MockerFixture, *, app_records ) -> None: sqlite_session.add_all( [ @@ -644,11 +688,7 @@ def test_init_uses_real_session_for_count_and_dependencies( ] ) sqlite_session.commit() - get_dataset_tools = mocker.patch.object( - module.DatasetRetrieverTool, - "get_dataset_tools", - return_value=["ds_tool"], - ) + get_dataset_tools = mocker.Mock(return_value=["ds_tool"]) llm = mocker.Mock(spec=LargeLanguageModel) llm.get_model_schema.return_value = mocker.Mock( features=[module.ModelFeature.STREAM_TOOL_CALL, module.ModelFeature.VISION] @@ -671,8 +711,11 @@ def test_init_uses_real_session_for_count_and_dependencies( app_generate = _app_generate(app_config=app_config, files=["file1"]) message = _message(message_id="msg1") + sqlite_session.add_all([make_app(app_id="app1", tenant_id="tenant"), _conversation(), message]) + sqlite_session.commit() initialized = BaseAgentRunner( - session=sqlite_session, + dataset_tools=get_dataset_tools, + records=app_records, tenant_id="tenant", application_generate_entity=app_generate, conversation=_conversation(), @@ -690,4 +733,5 @@ def test_init_uses_real_session_for_count_and_dependencies( assert initialized.dataset_tools == ["ds_tool"] assert initialized.agent_thought_count == 2 assert initialized.history_prompt_messages == [] - assert get_dataset_tools.call_args.kwargs["session"] is sqlite_session + assert "session" not in get_dataset_tools.call_args.kwargs + assert get_dataset_tools.call_args.kwargs["tenant_id"] == "tenant" diff --git a/api/tests/unit_tests/core/agent/test_cot_agent_runner.py b/api/tests/unit_tests/core/agent/test_cot_agent_runner.py index 771b819fa02119..3b0ddfc22044ab 100644 --- a/api/tests/unit_tests/core/agent/test_cot_agent_runner.py +++ b/api/tests/unit_tests/core/agent/test_cot_agent_runner.py @@ -9,7 +9,6 @@ from sqlalchemy.engine import Engine from sqlalchemy.orm import Session -from core.agent.cot_agent_runner import CotAgentRunner from core.agent.entities import AgentScratchpadUnit from core.agent.errors import AgentMaxIterationError from core.app.entities.app_invoke_entities import CreditUsageCreatedBy @@ -18,6 +17,7 @@ from libs.datetime_utils import naive_utc_now from models.enums import ConversationFromSource, MessageStatus from models.model import AppMode, Conversation, Message +from services.agent.chat.cot_runner import CotAgentRunner def _make_conversation(*, conversation_id: str = "conv1") -> Conversation: @@ -65,10 +65,10 @@ def _organize_prompt_messages(self): @pytest.fixture -def runner(mocker: MockerFixture, sqlite_engine: Engine) -> Iterator[DummyRunner]: +def runner(mocker: MockerFixture, sqlite_engine: Engine, app_records) -> Iterator[DummyRunner]: # Prevent BaseAgentRunner __init__ from hitting database mocker.patch( - "core.agent.base_agent_runner.BaseAgentRunner.organize_agent_history", + "services.agent.chat.base_runner.BaseAgentRunner.organize_agent_history", return_value=[], ) # Prepare required constructor dependencies for BaseAgentRunner @@ -123,6 +123,7 @@ def runner(mocker: MockerFixture, sqlite_engine: Engine) -> Iterator[DummyRunner runner.session = Session(sqlite_engine) try: + runner._records = app_records yield runner finally: runner.session.close() @@ -144,18 +145,6 @@ def test_fill_in_inputs(self, runner: DummyRunner, instruction, inputs, expected assert result == expected -class TestConvertDictToAction: - def test_convert_valid_dict(self, runner: DummyRunner): - action_dict = {"action": "test", "action_input": {"a": 1}} - action = runner._convert_dict_to_action(action_dict) - assert action.action_name == "test" - assert action.action_input == {"a": 1} - - def test_convert_missing_keys(self, runner: DummyRunner): - with pytest.raises(KeyError): - runner._convert_dict_to_action({"invalid": 1}) - - class TestFormatAssistantMessage: def test_format_assistant_message_multiple_scratchpads(self, runner: DummyRunner): sp1 = AgentScratchpadUnit( @@ -207,7 +196,7 @@ def test_format_with_action_and_observation(self, runner: DummyRunner): class TestHandleInvokeAction: def test_handle_invoke_action_tool_not_present(self, runner: DummyRunner): action = AgentScratchpadUnit.Action(action_name="missing", action_input={}) - response, meta = runner._handle_invoke_action(runner.session, action, {}, []) + response, meta = runner._handle_invoke_action(action, {}, []) assert "there is not a tool named" in response def test_tool_with_json_string_args(self, runner: DummyRunner, mocker: MockerFixture): @@ -216,18 +205,18 @@ def test_tool_with_json_string_args(self, runner: DummyRunner, mocker: MockerFix tool_instances = {"tool": tool_instance} mocker.patch( - "core.agent.cot_agent_runner.ToolEngine.agent_invoke", + "services.agent.chat.cot_runner.ToolEngine.agent_invoke", return_value=("result", [], MagicMock(to_dict=lambda: {})), ) - response, meta = runner._handle_invoke_action(runner.session, action, tool_instances, []) + response, meta = runner._handle_invoke_action(action, tool_instances, []) assert response == "result" class TestOrganizeHistoricPromptMessages: def test_empty_history(self, runner: DummyRunner, mocker: MockerFixture): mocker.patch( - "core.agent.cot_agent_runner.AgentHistoryPromptTransform.get_prompt", + "services.agent.chat.cot_runner.AgentHistoryPromptTransform.get_prompt", return_value=[], ) result = runner._organize_historic_prompt_messages([]) @@ -240,11 +229,11 @@ def test_run_handles_empty_parser_output(self, runner: DummyRunner, mocker: Mock message.id = "msg-id" mocker.patch( - "core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output", + "services.agent.chat.cot_runner.CotAgentOutputParser.handle_react_stream_output", return_value=[], ) - results = list(runner.run(runner.session, message, "query", {})) + results = list(runner.run(message, "query", {})) assert isinstance(results, list) assert "session" not in runner.create_agent_thought.call_args.kwargs assert all("session" not in call.kwargs for call in runner.save_agent_thought.call_args_list) @@ -256,19 +245,19 @@ def test_run_with_action_and_tool_invocation(self, runner: DummyRunner, mocker: action = AgentScratchpadUnit.Action(action_name="tool", action_input={}) mocker.patch( - "core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output", + "services.agent.chat.cot_runner.CotAgentOutputParser.handle_react_stream_output", return_value=[action], ) mocker.patch( - "core.agent.cot_agent_runner.ToolEngine.agent_invoke", + "services.agent.chat.cot_runner.ToolEngine.agent_invoke", return_value=("ok", [], MagicMock(to_dict=lambda: {})), ) runner.agent_callback = None with pytest.raises(AgentMaxIterationError): - list(runner.run(runner.session, message, "query", {"tool": MagicMock()})) + list(runner.run(message, "query", {"tool": MagicMock()})) def test_run_respects_max_iteration_boundary(self, runner: DummyRunner, mocker: MockerFixture): runner.app_config.agent.max_iteration = 1 @@ -278,30 +267,30 @@ def test_run_respects_max_iteration_boundary(self, runner: DummyRunner, mocker: action = AgentScratchpadUnit.Action(action_name="tool", action_input={}) mocker.patch( - "core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output", + "services.agent.chat.cot_runner.CotAgentOutputParser.handle_react_stream_output", return_value=[action], ) mocker.patch( - "core.agent.cot_agent_runner.ToolEngine.agent_invoke", + "services.agent.chat.cot_runner.ToolEngine.agent_invoke", return_value=("ok", [], MagicMock(to_dict=lambda: {})), ) runner.agent_callback = None with pytest.raises(AgentMaxIterationError): - list(runner.run(runner.session, message, "query", {"tool": MagicMock()})) + list(runner.run(message, "query", {"tool": MagicMock()})) def test_run_basic_flow(self, runner: DummyRunner, mocker: MockerFixture): message = _make_message() message.id = "msg-id" mocker.patch( - "core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output", + "services.agent.chat.cot_runner.CotAgentOutputParser.handle_react_stream_output", return_value=[], ) - results = list(runner.run(runner.session, message, "query", {"name": "John"})) + results = list(runner.run(message, "query", {"name": "John"})) assert results def test_run_max_iteration_error(self, runner: DummyRunner, mocker: MockerFixture): @@ -312,12 +301,12 @@ def test_run_max_iteration_error(self, runner: DummyRunner, mocker: MockerFixtur action = AgentScratchpadUnit.Action(action_name="tool", action_input={}) mocker.patch( - "core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output", + "services.agent.chat.cot_runner.CotAgentOutputParser.handle_react_stream_output", return_value=[action], ) with pytest.raises(AgentMaxIterationError): - list(runner.run(runner.session, message, "query", {})) + list(runner.run(message, "query", {})) def test_run_increase_usage_aggregation(self, runner: DummyRunner, mocker: MockerFixture): message = _make_message() @@ -343,7 +332,7 @@ def test_run_increase_usage_aggregation(self, runner: DummyRunner, mocker: Mocke action = AgentScratchpadUnit.Action(action_name="tool", action_input={}) handle_output = mocker.patch( - "core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output", + "services.agent.chat.cot_runner.CotAgentOutputParser.handle_react_stream_output", side_effect=[ [action], [], @@ -358,7 +347,7 @@ def _handle_side_effect(chunks, usage_dict): handle_output.side_effect = _handle_side_effect runner.model_instance.invoke_llm = MagicMock(return_value=[]) mocker.patch( - "core.agent.cot_agent_runner.ToolEngine.agent_invoke", + "services.agent.chat.cot_runner.ToolEngine.agent_invoke", return_value=("ok", [], MagicMock(to_dict=lambda: {})), ) @@ -366,7 +355,7 @@ def _handle_side_effect(chunks, usage_dict): fake_prompt_tool.name = "tool" runner._init_prompt_tools = MagicMock(return_value=({"tool": MagicMock()}, [fake_prompt_tool])) - results = list(runner.run(runner.session, message, "query", {})) + results = list(runner.run(message, "query", {})) final_usage = results[-1].delta.usage assert final_usage is not None assert final_usage.prompt_tokens == 2 @@ -400,12 +389,12 @@ def provider_chunks(): runner.model_instance.invoke_llm.return_value = provider_chunks() mocker.patch( - "core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output", + "services.agent.chat.cot_runner.CotAgentOutputParser.handle_react_stream_output", side_effect=lambda chunks, _usage: list(chunks), ) - results = list(runner.run(session, message, "query", {})) - assert events == ["commit", "close", "first-chunk"] + results = list(runner.run(message, "query", {})) + assert events == ["first-chunk"] assert runner.model_instance.invoke_llm.call_args.kwargs["request_metadata"] == { "app_id": "app", "app_type": CreditUsageAppType.AGENT, @@ -418,13 +407,13 @@ def test_run_usage_missing_key_branch(self, runner: DummyRunner, mocker: MockerF message.id = "msg-id" mocker.patch( - "core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output", + "services.agent.chat.cot_runner.CotAgentOutputParser.handle_react_stream_output", return_value=[], ) runner.model_instance.invoke_llm = MagicMock(return_value=[]) - list(runner.run(runner.session, message, "query", {})) + list(runner.run(message, "query", {})) def test_run_prompt_tool_update_branch(self, runner: DummyRunner, mocker: MockerFixture): message = _make_message() @@ -435,12 +424,12 @@ def test_run_prompt_tool_update_branch(self, runner: DummyRunner, mocker: Mocker # First iteration → action # Second iteration → no action (empty list) mocker.patch( - "core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output", + "services.agent.chat.cot_runner.CotAgentOutputParser.handle_react_stream_output", side_effect=[[action], []], ) mocker.patch( - "core.agent.cot_agent_runner.ToolEngine.agent_invoke", + "services.agent.chat.cot_runner.ToolEngine.agent_invoke", return_value=("ok", [], MagicMock(to_dict=lambda: {})), ) @@ -454,7 +443,7 @@ def test_run_prompt_tool_update_branch(self, runner: DummyRunner, mocker: Mocker runner.update_prompt_message_tool = MagicMock() runner.agent_callback = None - list(runner.run(runner.session, message, "query", {})) + list(runner.run(message, "query", {})) runner.update_prompt_message_tool.assert_called_once() @@ -501,12 +490,12 @@ def test_tool_with_invalid_json_string_args(self, runner: DummyRunner, mocker: M tool_instances = {"tool": tool_instance} mocker.patch( - "core.agent.cot_agent_runner.ToolEngine.agent_invoke", + "services.agent.chat.cot_runner.ToolEngine.agent_invoke", return_value=("ok", ["file1"], MagicMock(to_dict=lambda: {"k": "v"})), ) message_file_ids = [] - response, meta = runner._handle_invoke_action(runner.session, action, tool_instances, message_file_ids) + response, meta = runner._handle_invoke_action(action, tool_instances, message_file_ids) assert response == "ok" assert message_file_ids == ["file1"] @@ -537,7 +526,7 @@ def test_user_message_flushes_scratchpad(self, runner: DummyRunner, mocker: Mock runner.history_prompt_messages = [user_message] mock_transform = mocker.patch( - "core.agent.cot_agent_runner.AgentHistoryPromptTransform", + "services.agent.chat.cot_runner.AgentHistoryPromptTransform", ) mock_transform.return_value.get_prompt.return_value = ["final"] @@ -557,7 +546,7 @@ def test_agent_history_transform_invocation(self, runner: DummyRunner, mocker: M mock_transform.get_prompt.return_value = [] mocker.patch( - "core.agent.cot_agent_runner.AgentHistoryPromptTransform", + "services.agent.chat.cot_runner.AgentHistoryPromptTransform", return_value=mock_transform, ) @@ -572,11 +561,11 @@ def test_run_with_no_action_final_answer_empty(self, runner: DummyRunner, mocker message.id = "msg-id" mocker.patch( - "core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output", + "services.agent.chat.cot_runner.CotAgentOutputParser.handle_react_stream_output", return_value=["thinking"], ) - results = list(runner.run(runner.session, message, "query", {})) + results = list(runner.run(message, "query", {})) assert any(hasattr(r, "delta") for r in results) def test_run_with_final_answer_action_string(self, runner: DummyRunner, mocker: MockerFixture): @@ -586,11 +575,11 @@ def test_run_with_final_answer_action_string(self, runner: DummyRunner, mocker: action = AgentScratchpadUnit.Action(action_name="Final Answer", action_input="done") mocker.patch( - "core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output", + "services.agent.chat.cot_runner.CotAgentOutputParser.handle_react_stream_output", return_value=[action], ) - results = list(runner.run(runner.session, message, "query", {})) + results = list(runner.run(message, "query", {})) assert results[-1].delta.message.content == "done" def test_run_with_final_answer_action_dict(self, runner: DummyRunner, mocker: MockerFixture): @@ -600,11 +589,11 @@ def test_run_with_final_answer_action_dict(self, runner: DummyRunner, mocker: Mo action = AgentScratchpadUnit.Action(action_name="Final Answer", action_input={"a": 1}) mocker.patch( - "core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output", + "services.agent.chat.cot_runner.CotAgentOutputParser.handle_react_stream_output", return_value=[action], ) - results = list(runner.run(runner.session, message, "query", {})) + results = list(runner.run(message, "query", {})) assert json.loads(results[-1].delta.message.content) == {"a": 1} def test_run_with_string_final_answer(self, runner: DummyRunner, mocker: MockerFixture): @@ -615,9 +604,9 @@ def test_run_with_string_final_answer(self, runner: DummyRunner, mocker: MockerF action = AgentScratchpadUnit.Action(action_name="Final Answer", action_input="12345") mocker.patch( - "core.agent.cot_agent_runner.CotAgentOutputParser.handle_react_stream_output", + "services.agent.chat.cot_runner.CotAgentOutputParser.handle_react_stream_output", return_value=[action], ) - results = list(runner.run(runner.session, message, "query", {})) + results = list(runner.run(message, "query", {})) assert results[-1].delta.message.content == "12345" diff --git a/api/tests/unit_tests/core/agent/test_cot_chat_agent_runner.py b/api/tests/unit_tests/core/agent/test_cot_chat_agent_runner.py index 8e7093fd123a1d..1213e5464cff5d 100644 --- a/api/tests/unit_tests/core/agent/test_cot_chat_agent_runner.py +++ b/api/tests/unit_tests/core/agent/test_cot_chat_agent_runner.py @@ -3,8 +3,8 @@ import pytest from pytest_mock import MockerFixture -from core.agent.cot_chat_agent_runner import CotChatAgentRunner from graphon.model_runtime.entities.message_entities import TextPromptMessageContent +from services.agent.chat.cot_chat_runner import CotChatAgentRunner from tests.unit_tests.core.agent.conftest import ( DummyAgentConfig, DummyAppConfig, @@ -61,7 +61,7 @@ def test_organize_system_prompt_success(self, runner, mocker: MockerFixture): runner.app_config = DummyAppConfig(DummyAgentConfig(DummyPrompt(first_prompt))) mocker.patch( - "core.agent.cot_chat_agent_runner.jsonable_encoder", + "services.agent.chat.cot_chat_runner.jsonable_encoder", return_value=[{"name": "tool1"}, {"name": "tool2"}], ) @@ -91,8 +91,8 @@ def test_organize_user_query_no_files(self, runner, files): assert len(result) == 1 assert result[0].content == "query" - @patch("core.agent.cot_chat_agent_runner.UserPromptMessage") - @patch("core.agent.cot_chat_agent_runner.file_manager.to_prompt_message_content") + @patch("services.agent.chat.cot_chat_runner.UserPromptMessage") + @patch("services.agent.chat.cot_chat_runner.file_manager.to_prompt_message_content") def test_organize_user_query_with_image_file_default_config(self, mock_to_prompt, mock_user_prompt, runner): from graphon.model_runtime.entities.message_entities import ImagePromptMessageContent @@ -116,8 +116,8 @@ def test_organize_user_query_with_image_file_default_config(self, mock_to_prompt image_detail_config=ImagePromptMessageContent.DETAIL.LOW, ) - @patch("core.agent.cot_chat_agent_runner.UserPromptMessage") - @patch("core.agent.cot_chat_agent_runner.file_manager.to_prompt_message_content") + @patch("services.agent.chat.cot_chat_runner.UserPromptMessage") + @patch("services.agent.chat.cot_chat_runner.file_manager.to_prompt_message_content") def test_organize_user_query_with_image_file_high_detail(self, mock_to_prompt, mock_user_prompt, runner): from graphon.model_runtime.entities.message_entities import ImagePromptMessageContent @@ -143,7 +143,7 @@ def test_organize_user_query_with_image_file_high_detail(self, mock_to_prompt, m image_detail_config=ImagePromptMessageContent.DETAIL.HIGH, ) - @patch("core.agent.cot_chat_agent_runner.file_manager.to_prompt_message_content") + @patch("services.agent.chat.cot_chat_runner.file_manager.to_prompt_message_content") def test_organize_user_query_with_text_file_no_config(self, mock_to_prompt, runner): mock_to_prompt.return_value = TextPromptMessageContent(data="file_content") runner.files = ["file1"] diff --git a/api/tests/unit_tests/core/agent/test_cot_completion_agent_runner.py b/api/tests/unit_tests/core/agent/test_cot_completion_agent_runner.py index e910c59d3687bb..4a67dc39521f27 100644 --- a/api/tests/unit_tests/core/agent/test_cot_completion_agent_runner.py +++ b/api/tests/unit_tests/core/agent/test_cot_completion_agent_runner.py @@ -3,13 +3,13 @@ import pytest from pytest_mock import MockerFixture -from core.agent.cot_completion_agent_runner import CotCompletionAgentRunner from graphon.model_runtime.entities.message_entities import ( AssistantPromptMessage, ImagePromptMessageContent, TextPromptMessageContent, UserPromptMessage, ) +from services.agent.chat.cot_completion_runner import CotCompletionAgentRunner # ----------------------------- # Fixtures @@ -26,7 +26,7 @@ def runner(mocker: MockerFixture, dummy_tool_factory): runner._agent_scratchpad = [] mocker.patch( - "core.agent.cot_completion_agent_runner.jsonable_encoder", + "services.agent.chat.cot_completion_runner.jsonable_encoder", side_effect=lambda tools: [{"name": t.name} for t in tools], ) diff --git a/api/tests/unit_tests/core/agent/test_fc_agent_runner.py b/api/tests/unit_tests/core/agent/test_fc_agent_runner.py index 4d98d9a7b37193..3885d3056912d2 100644 --- a/api/tests/unit_tests/core/agent/test_fc_agent_runner.py +++ b/api/tests/unit_tests/core/agent/test_fc_agent_runner.py @@ -12,7 +12,6 @@ from sqlalchemy.orm import Session from core.agent.errors import AgentMaxIterationError -from core.agent.fc_agent_runner import FunctionCallAgentRunner from core.app.apps.base_app_queue_manager import PublishFrom from core.app.entities.app_invoke_entities import CreditUsageCreatedBy from core.app.entities.queue_entities import QueueMessageFileEvent @@ -28,6 +27,7 @@ from libs.datetime_utils import naive_utc_now from models.enums import ConversationFromSource, CreatorUserRole, MessageStatus from models.model import AppMode, Conversation, Message, StorageType, UploadFile +from services.agent.chat.function_call_runner import FunctionCallAgentRunner # ============================== # Dummy Helper Classes @@ -110,16 +110,16 @@ def __init__( @pytest.fixture -def runner(mocker: MockerFixture, sqlite_engine: Engine) -> Iterator[FunctionCallAgentRunner]: +def runner(mocker: MockerFixture, sqlite_engine: Engine, app_records) -> Iterator[FunctionCallAgentRunner]: # Completely bypass BaseAgentRunner __init__ to avoid DB / Flask context mocker.patch( - "core.agent.base_agent_runner.BaseAgentRunner.__init__", + "services.agent.chat.base_runner.BaseAgentRunner.__init__", return_value=None, ) # Patch streaming chunk models to avoid validation on dummy message objects - mocker.patch("core.agent.fc_agent_runner.LLMResultChunk", MagicMock) - mocker.patch("core.agent.fc_agent_runner.LLMResultChunkDelta", MagicMock) + mocker.patch("services.agent.chat.function_call_runner.LLMResultChunk", MagicMock) + mocker.patch("services.agent.chat.function_call_runner.LLMResultChunkDelta", MagicMock) app_config = MagicMock() app_config.app_id = "app" @@ -182,6 +182,7 @@ def runner(mocker: MockerFixture, sqlite_engine: Engine) -> Iterator[FunctionCal runner.update_prompt_message_tool = MagicMock() try: + runner._records = app_records yield runner finally: runner.session.close() @@ -281,7 +282,7 @@ def test_with_none_query(self, runner: FunctionCallAgentRunner): def test_with_files_uses_image_detail_config(self, runner: FunctionCallAgentRunner, mocker: MockerFixture): file_content = TextPromptMessageContent(data="file-content") mock_to_prompt = mocker.patch( - "core.agent.fc_agent_runner.file_manager.to_prompt_message_content", + "services.agent.chat.function_call_runner.file_manager.to_prompt_message_content", return_value=file_content, ) @@ -350,28 +351,23 @@ def test_returns_empty_when_vision_disabled(self, runner: FunctionCallAgentRunne tool.__class__.__name__ = "DatasetRetrieverTool" response = "![image](http://localhost:5001/files/890985e9-c2f1-484e-bc7b-62010a337e6d/file-preview)" - assert runner._build_dataset_tool_image_contents(runner.session, response, tool) == [] + assert runner._build_dataset_tool_image_contents(response, tool) == [] def test_builds_image_contents_from_dataset_tool_preview_links( self, runner: FunctionCallAgentRunner, mocker: MockerFixture ): - from core.tools.utils.dataset_retriever_tool import DatasetRetrieverTool + from services.tools.dataset.tool import DatasetRetrieverTool runner.vision_enabled = True image_content = ImagePromptMessageContent(format="url", mime_type="image/png") to_prompt_content = mocker.patch( - "core.agent.fc_agent_runner.file_manager.to_prompt_message_content", + "services.agent.chat.function_call_runner.file_manager.to_prompt_message_content", return_value=image_content, ) - grant_access = mocker.patch("core.agent.fc_agent_runner.grant_upload_file_access") - sign_preview = mocker.patch( - "core.agent.fc_agent_runner.sign_upload_file_preview_url", - return_value="http://localhost:5001/files/file-id/file-preview?sign=1", - ) - build_reference = mocker.patch("core.agent.fc_agent_runner.build_file_reference", return_value="file-ref") + grant_access = mocker.patch("services.agent.chat.function_call_runner.grant_upload_file_access") upload_file = UploadFile( - tenant_id="00000000-0000-0000-0000-000000000001", + tenant_id=runner.tenant_id, storage_type=StorageType.LOCAL, key="image_files/chart.png", name="chart.png", @@ -409,14 +405,12 @@ def test_builds_image_contents_from_dataset_tool_preview_links( ) tool = MagicMock(spec=DatasetRetrieverTool) - contents = runner._build_dataset_tool_image_contents(session, response, tool) + contents = runner._build_dataset_tool_image_contents(response, tool) assert contents == [image_content] assert contents[0].type == PromptMessageContentType.IMAGE grant_access.assert_called_once() assert list(grant_access.call_args.args[0]) == ["890985e9-c2f1-484e-bc7b-62010a337e6d"] - sign_preview.assert_called_once_with(upload_file.id, upload_file.extension) - build_reference.assert_called_once_with(record_id=str(upload_file.id)) to_prompt_content.assert_called_once() @@ -433,7 +427,7 @@ def test_run_non_streaming_no_tool_calls(self, runner: FunctionCallAgentRunner): runner.model_instance.invoke_llm.return_value = result - outputs = list(runner.run(runner.session, message, "query")) + outputs = list(runner.run(message, "query")) assert len(outputs) == 1 assert "session" not in runner.create_agent_thought.call_args.kwargs assert "session" not in runner.save_agent_thought.call_args.kwargs @@ -475,8 +469,8 @@ def generator(): runner.model_instance.invoke_llm.return_value = generator() - outputs = list(runner.run(session, message, "query")) - assert events == ["commit", "close", "first-chunk"] + outputs = list(runner.run(message, "query")) + assert events == ["first-chunk"] assert len(outputs) == 1 def test_run_streaming_tool_calls_list_content(self, runner: FunctionCallAgentRunner): @@ -499,7 +493,7 @@ def generator(): runner.model_instance.invoke_llm.side_effect = [generator(), final_result] - outputs = list(runner.run(runner.session, message, "query")) + outputs = list(runner.run(message, "query")) assert len(outputs) >= 1 def test_run_non_streaming_list_content(self, runner: FunctionCallAgentRunner): @@ -510,7 +504,7 @@ def test_run_non_streaming_list_content(self, runner: FunctionCallAgentRunner): runner.model_instance.invoke_llm.return_value = result - outputs = list(runner.run(runner.session, message, "query")) + outputs = list(runner.run(message, "query")) assert len(outputs) == 1 assert runner.save_agent_thought.call_args.kwargs["thought"] == "hi" @@ -537,9 +531,9 @@ def flaky_dumps(obj, *args, **kwargs): return real_dumps(obj, *args, **kwargs) raise TypeError("boom") - mocker.patch("core.agent.fc_agent_runner.json.dumps", side_effect=flaky_dumps) + mocker.patch("services.agent.chat.function_call_runner.json.dumps", side_effect=flaky_dumps) - outputs = list(runner.run(runner.session, message, "query")) + outputs = list(runner.run(message, "query")) assert len(outputs) == 1 def test_run_with_missing_tool_instance(self, runner: FunctionCallAgentRunner): @@ -557,7 +551,7 @@ def test_run_with_missing_tool_instance(self, runner: FunctionCallAgentRunner): runner.model_instance.invoke_llm.side_effect = [result, final_result] - outputs = list(runner.run(runner.session, message, "query")) + outputs = list(runner.run(message, "query")) assert len(outputs) >= 1 def test_run_with_tool_instance_and_files(self, runner: FunctionCallAgentRunner, mocker: MockerFixture): @@ -582,11 +576,11 @@ def test_run_with_tool_instance_and_files(self, runner: FunctionCallAgentRunner, tool_invoke_meta = MagicMock() tool_invoke_meta.to_dict.return_value = {"ok": True} mocker.patch( - "core.agent.fc_agent_runner.ToolEngine.agent_invoke", + "services.agent.chat.function_call_runner.ToolEngine.agent_invoke", return_value=("ok", ["file1"], tool_invoke_meta), ) - outputs = list(runner.run(runner.session, message, "query")) + outputs = list(runner.run(message, "query")) assert len(outputs) >= 1 assert any( isinstance(call.args[0], QueueMessageFileEvent) @@ -611,4 +605,4 @@ def test_run_max_iteration_error(self, runner: FunctionCallAgentRunner): runner.model_instance.invoke_llm.return_value = result with pytest.raises(AgentMaxIterationError): - list(runner.run(runner.session, message, "query")) + list(runner.run(message, "query")) diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py index 018482b4f8dfd7..fb35d10836fd89 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py @@ -10,16 +10,10 @@ import pytest from pydantic import BaseModel, ValidationError from sqlalchemy import Engine, event -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from constants import UUID_NIL from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig -from core.app.apps.advanced_chat.app_generator import AdvancedChatAppGenerator -from core.app.apps.advanced_chat.generate_task_pipeline import ( - ConversationSnapshot, - MessageSnapshot, - WorkflowSnapshot, -) from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom from core.ops.ops_trace_manager import TraceQueueManager @@ -28,6 +22,9 @@ from models.enums import ConversationFromSource, EndUserType, MessageStatus from models.model import App, AppMode, Conversation, EndUser, Message from models.workflow import Workflow, WorkflowType +from services.app.generation.ports import ConversationSnapshot, MessageSnapshot, WorkflowSnapshot +from services.workflow.execution.adapters.chatflow.app_generator import AdvancedChatAppGenerator +from services.workflow.execution.ports import WorkflowRuntime from tests.unit_tests.config_override import apply_config_overrides @@ -109,8 +106,8 @@ def _make_message( class TestAdvancedChatAppGeneratorValidation: - def test_generate_requires_query(self, unbound_session: Session): - generator = AdvancedChatAppGenerator() + def test_generate_requires_query(self, unbound_session: Session, *, workflow_runtime: WorkflowRuntime): + generator = AdvancedChatAppGenerator(runtime=workflow_runtime) with pytest.raises(ValueError, match="query is required"): generator.generate( @@ -121,11 +118,10 @@ def test_generate_requires_query(self, unbound_session: Session): invoke_from=InvokeFrom.WEB_APP, workflow_run_id="run-id", streaming=False, - session=unbound_session, ) - def test_generate_requires_string_query(self, unbound_session: Session): - generator = AdvancedChatAppGenerator() + def test_generate_requires_string_query(self, unbound_session: Session, *, workflow_runtime: WorkflowRuntime): + generator = AdvancedChatAppGenerator(runtime=workflow_runtime) with pytest.raises(ValueError, match="query must be a string"): generator.generate( @@ -136,11 +132,12 @@ def test_generate_requires_string_query(self, unbound_session: Session): invoke_from=InvokeFrom.WEB_APP, workflow_run_id="run-id", streaming=False, - session=unbound_session, ) - def test_single_iteration_generate_validates_args(self, unbound_session: Session): - generator = AdvancedChatAppGenerator() + def test_single_iteration_generate_validates_args( + self, unbound_session: Session, *, workflow_runtime: WorkflowRuntime + ): + generator = AdvancedChatAppGenerator(runtime=workflow_runtime) with pytest.raises(ValueError, match="node_id is required"): generator.single_iteration_generate( @@ -150,7 +147,6 @@ def test_single_iteration_generate_validates_args(self, unbound_session: Session user=_make_account(), args={"inputs": {}}, streaming=False, - session=unbound_session, ) with pytest.raises(ValueError, match="inputs is required"): @@ -161,11 +157,10 @@ def test_single_iteration_generate_validates_args(self, unbound_session: Session user=_make_account(), args={}, streaming=False, - session=unbound_session, ) - def test_single_loop_generate_validates_args(self, unbound_session: Session): - generator = AdvancedChatAppGenerator() + def test_single_loop_generate_validates_args(self, unbound_session: Session, *, workflow_runtime: WorkflowRuntime): + generator = AdvancedChatAppGenerator(runtime=workflow_runtime) with pytest.raises(ValueError, match="node_id is required"): generator.single_loop_generate( @@ -173,9 +168,8 @@ def test_single_loop_generate_validates_args(self, unbound_session: Session): workflow=_make_workflow(), node_id="", user=_make_account(), - args=SimpleNamespace(inputs={}), + args={"inputs": {}}, streaming=False, - session=unbound_session, ) with pytest.raises(ValueError, match="inputs is required"): @@ -184,9 +178,8 @@ def test_single_loop_generate_validates_args(self, unbound_session: Session): workflow=_make_workflow(), node_id="node", user=_make_account(), - args=SimpleNamespace(inputs=None), + args={"inputs": None}, streaming=False, - session=unbound_session, ) @@ -203,9 +196,14 @@ def _build_app_config() -> WorkflowUIBasedAppConfig: ) def test_generate_loads_conversation_and_files( - self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session, sqlite_engine: Engine + self, + monkeypatch: pytest.MonkeyPatch, + unbound_session: Session, + sqlite_engine: Engine, + *, + workflow_runtime: WorkflowRuntime, ): - generator = AdvancedChatAppGenerator() + generator = AdvancedChatAppGenerator(runtime=workflow_runtime) app_config = self._build_app_config() conversation = _make_conversation() @@ -216,33 +214,22 @@ def test_generate_loads_conversation_and_files( get_conversation = MagicMock(return_value=conversation) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.ConversationService.get_conversation", + workflow_runtime.chat_records, + "conversation", get_conversation, ) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.FileUploadConfigManager.convert", + "services.workflow.execution.adapters.chatflow.app_generator.FileUploadConfigManager.convert", lambda *args, **kwargs: {"enabled": True}, ) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.file_factory.build_from_mappings", + "services.workflow.execution.adapters.chatflow.app_generator.file_factory.build_from_mappings", lambda **kwargs: build_files_called.update({"called": True}) or built_files, ) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.AdvancedChatAppConfigManager.get_app_config", + "services.workflow.execution.adapters.chatflow.app_generator.AdvancedChatAppConfigManager.get_app_config", lambda **kwargs: app_config, ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.DifyCoreRepositoryFactory.create_workflow_execution_repository", - lambda **kwargs: SimpleNamespace(), - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", - lambda **kwargs: SimpleNamespace(), - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=sqlite_engine, session=unbound_session), - ) monkeypatch.setattr(generator, "_prepare_user_inputs", lambda **kwargs: kwargs["user_inputs"]) DummyTraceQueueManager = type( @@ -254,7 +241,9 @@ def test_generate_loads_conversation_and_files( ) }, ) - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.TraceQueueManager", DummyTraceQueueManager) + monkeypatch.setattr( + "services.workflow.execution.adapters.chatflow.app_generator.TraceQueueManager", DummyTraceQueueManager + ) def _fake_generate(**kwargs): captured.update(kwargs) @@ -280,18 +269,21 @@ def _fake_generate(**kwargs): invoke_from=InvokeFrom.WEB_APP, workflow_run_id="run-id", streaming=False, - session=session, ) assert result == {"ok": True} assert captured["conversation"] is conversation assert captured["application_generate_entity"].files == built_files - assert captured["session"] is session assert build_files_called["called"] is True - assert get_conversation.call_args.kwargs["session"] is session - def test_resume_delegates_to_generate(self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session): - generator = AdvancedChatAppGenerator() + def test_resume_delegates_to_generate( + self, + monkeypatch: pytest.MonkeyPatch, + unbound_session: Session, + *, + workflow_runtime: WorkflowRuntime, + ): + generator = AdvancedChatAppGenerator(runtime=workflow_runtime) existing_trace_manager = SimpleNamespace(app_id="existing-app", user_id="existing-user") application_generate_entity = AdvancedChatAppGenerateEntity.model_construct( task_id="task", @@ -324,7 +316,6 @@ def _fake_generate(**kwargs): user=_make_end_user(), conversation=_make_conversation(), message=_make_message(), - session=unbound_session, application_generate_entity=application_generate_entity, workflow_execution_repository=SimpleNamespace(), workflow_node_execution_repository=SimpleNamespace(), @@ -338,9 +329,13 @@ def _fake_generate(**kwargs): assert captured_graph_runtime_state is not None def test_single_iteration_generate_builds_debug_task( - self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session, sqlite_engine: Engine + self, + monkeypatch: pytest.MonkeyPatch, + unbound_session: Session, + sqlite_engine: Engine, + *, + workflow_runtime: WorkflowRuntime, ): - generator = AdvancedChatAppGenerator() app_config = self._build_app_config() captured: dict[str, object] = {} prefill_calls: list[object] = [] @@ -350,31 +345,15 @@ def test_single_iteration_generate_builds_debug_task( session = unbound_session monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.AdvancedChatAppConfigManager.get_app_config", + "services.workflow.execution.adapters.chatflow.app_generator.AdvancedChatAppConfigManager.get_app_config", lambda **kwargs: app_config, ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.DifyCoreRepositoryFactory.create_workflow_execution_repository", - lambda **kwargs: SimpleNamespace(repo="execution"), - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", - lambda **kwargs: SimpleNamespace(repo="node"), - ) - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.DraftVarLoader", lambda **kwargs: var_loader) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=sqlite_engine, session=unbound_session), - ) - - class _DraftVarService: - def __init__(self, session): - draft_sessions.append(session) - def prefill_conversation_variable_default_values(self, workflow, user_id): - prefill_calls.append((workflow, user_id)) + def load_variables(workflow, user_id): + prefill_calls.append((workflow, user_id)) + return var_loader - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.WorkflowDraftVariableService", _DraftVarService) + generator = AdvancedChatAppGenerator(draft_variable_loader=load_variables, runtime=workflow_runtime) def _fake_generate(**kwargs): captured.update(kwargs) @@ -389,21 +368,22 @@ def _fake_generate(**kwargs): user=_make_account(), args={"inputs": {"foo": "bar"}, "trace_session_id": "session-1"}, streaming=False, - session=session, ) assert result == {"ok": True} assert prefill_calls == [(workflow, "user-id")] - assert draft_sessions == [session] assert captured["variable_loader"] is var_loader - assert captured["session"] is session assert captured["application_generate_entity"].single_iteration_run.node_id == "node-1" assert captured["application_generate_entity"].extras["trace_session_id"] == "session-1" def test_single_loop_generate_builds_debug_task( - self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session, sqlite_engine: Engine + self, + monkeypatch: pytest.MonkeyPatch, + unbound_session: Session, + sqlite_engine: Engine, + *, + workflow_runtime: WorkflowRuntime, ): - generator = AdvancedChatAppGenerator() app_config = self._build_app_config() captured: dict[str, object] = {} prefill_calls: list[object] = [] @@ -413,31 +393,15 @@ def test_single_loop_generate_builds_debug_task( session = unbound_session monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.AdvancedChatAppConfigManager.get_app_config", + "services.workflow.execution.adapters.chatflow.app_generator.AdvancedChatAppConfigManager.get_app_config", lambda **kwargs: app_config, ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.DifyCoreRepositoryFactory.create_workflow_execution_repository", - lambda **kwargs: SimpleNamespace(repo="execution"), - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", - lambda **kwargs: SimpleNamespace(repo="node"), - ) - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.DraftVarLoader", lambda **kwargs: var_loader) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=sqlite_engine, session=unbound_session), - ) - class _DraftVarService: - def __init__(self, session): - draft_sessions.append(session) + def load_variables(workflow, user_id): + prefill_calls.append((workflow, user_id)) + return var_loader - def prefill_conversation_variable_default_values(self, workflow, user_id): - prefill_calls.append((workflow, user_id)) - - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.WorkflowDraftVariableService", _DraftVarService) + generator = AdvancedChatAppGenerator(draft_variable_loader=load_variables, runtime=workflow_runtime) def _fake_generate(**kwargs): captured.update(kwargs) @@ -450,23 +414,25 @@ def _fake_generate(**kwargs): workflow=workflow, node_id="node-2", user=_make_account(), - args=SimpleNamespace(inputs={"foo": "bar"}, trace_session_id="session-1"), + args={"inputs": {"foo": "bar"}, "trace_session_id": "session-1"}, streaming=False, - session=session, ) assert result == {"ok": True} assert prefill_calls == [(workflow, "user-id")] - assert draft_sessions == [session] assert captured["variable_loader"] is var_loader - assert captured["session"] is session assert captured["application_generate_entity"].single_loop_run.node_id == "node-2" assert captured["application_generate_entity"].extras["trace_session_id"] == "session-1" def test_generate_internal_flow_initial_conversation_with_pause_layer( - self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + self, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_engine: Engine, + *, + workflow_runtime: WorkflowRuntime, ): - generator = AdvancedChatAppGenerator() + generator = AdvancedChatAppGenerator(runtime=workflow_runtime) generator._dialogue_count = 0 app_config = self._build_app_config() @@ -500,25 +466,25 @@ def _record_commit(session: Session) -> None: captured: dict[str, object] = {} thread_data: dict[str, object] = {} init_records = MagicMock(return_value=(conversation, message)) - get_thread_messages_length = MagicMock(return_value=2) + get_thread_messages_length = MagicMock(return_value=3) - monkeypatch.setattr(generator, "_init_generate_records", init_records) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.get_thread_messages_length", get_thread_messages_length - ) + monkeypatch.setattr(generator._runtime.chat_records, "initialize", init_records) + monkeypatch.setattr(generator._runtime.chat_records, "dialogue_count", get_thread_messages_length) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.MessageBasedAppQueueManager", + "services.workflow.execution.adapters.chatflow.app_generator.MessageBasedAppQueueManager", lambda **kwargs: SimpleNamespace(**kwargs), ) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.PauseStatePersistenceLayer", + "services.workflow.execution.adapters.chatflow.app_generator.PauseStatePersistenceLayer", lambda **kwargs: "pause-layer", ) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.current_app", + "services.workflow.execution.adapters.chatflow.app_generator.current_app", SimpleNamespace(_get_current_object=lambda: SimpleNamespace(name="flask")), ) - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.contextvars.copy_context", lambda: "ctx") + monkeypatch.setattr( + "services.workflow.execution.adapters.chatflow.app_generator.contextvars.copy_context", lambda: "ctx" + ) class _Thread: def __init__(self, *, target, kwargs): @@ -535,11 +501,7 @@ def join(self, timeout): def is_alive(self): return False - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.threading.Thread", _Thread) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=sqlite_engine, session=sqlite_session), - ) + monkeypatch.setattr("services.workflow.execution.adapters.chatflow.app_generator.threading.Thread", _Thread) monkeypatch.setattr(generator, "_get_draft_var_saver_factory", lambda *args, **kwargs: "draft-factory") monkeypatch.setattr( generator, @@ -547,7 +509,7 @@ def is_alive(self): lambda **kwargs: captured.update(kwargs) or {"raw": True}, ) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.AdvancedChatAppGenerateResponseConverter.convert", + "services.workflow.execution.adapters.chatflow.app_generator.AdvancedChatAppGenerateResponseConverter.convert", lambda response, invoke_from: {"response": response, "invoke_from": invoke_from}, ) @@ -558,7 +520,6 @@ def is_alive(self): user=_make_account(account_id="user"), invoke_from=InvokeFrom.WEB_APP, application_generate_entity=application_generate_entity, - session=sqlite_session, workflow_execution_repository=SimpleNamespace(), workflow_node_execution_repository=SimpleNamespace(), conversation=None, @@ -573,19 +534,23 @@ def is_alive(self): assert thread_data["join_timeout"] == 300 assert "pause-layer" in thread_data["kwargs"]["graph_engine_layers"] assert generator._dialogue_count == 3 - assert init_records.call_args.kwargs["session"] is sqlite_session - get_thread_messages_length.assert_called_once_with(conversation.id, session=sqlite_session) - assert commit_count == 1 - assert json.loads(conversation.override_model_configs) == {"feature": True} + get_thread_messages_length.assert_called_once_with(conversation.id) + assert commit_count == 0 + assert init_records.call_args.kwargs["seed"].conversation["override_model_configs"] == workflow.features assert captured["draft_var_saver_factory"] == "draft-factory" assert isinstance(captured["workflow"], WorkflowSnapshot) assert isinstance(captured["conversation"], ConversationSnapshot) assert isinstance(captured["message"], MessageSnapshot) def test_generate_internal_flow_with_existing_records_skips_init( - self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + self, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_engine: Engine, + *, + workflow_runtime: WorkflowRuntime, ): - generator = AdvancedChatAppGenerator() + generator = AdvancedChatAppGenerator(runtime=workflow_runtime) generator._dialogue_count = 0 app_config = self._build_app_config() @@ -620,19 +585,19 @@ def _record_commit(session: Session) -> None: get_thread_messages_length = MagicMock(return_value=0) thread_data: dict[str, object] = {} - monkeypatch.setattr(generator, "_init_generate_records", init_records) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.get_thread_messages_length", get_thread_messages_length - ) + monkeypatch.setattr(generator._runtime.chat_records, "initialize", init_records) + monkeypatch.setattr(generator._runtime.chat_records, "dialogue_count", get_thread_messages_length) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.MessageBasedAppQueueManager", + "services.workflow.execution.adapters.chatflow.app_generator.MessageBasedAppQueueManager", lambda **kwargs: SimpleNamespace(**kwargs), ) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.current_app", + "services.workflow.execution.adapters.chatflow.app_generator.current_app", SimpleNamespace(_get_current_object=lambda: SimpleNamespace(name="flask")), ) - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.contextvars.copy_context", lambda: "ctx") + monkeypatch.setattr( + "services.workflow.execution.adapters.chatflow.app_generator.contextvars.copy_context", lambda: "ctx" + ) class _Thread: def __init__(self, *, target, kwargs): @@ -649,11 +614,7 @@ def join(self, timeout): def is_alive(self): return False - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.threading.Thread", _Thread) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=sqlite_engine, session=sqlite_session), - ) + monkeypatch.setattr("services.workflow.execution.adapters.chatflow.app_generator.threading.Thread", _Thread) monkeypatch.setattr(generator, "_get_draft_var_saver_factory", lambda *args, **kwargs: "draft-factory") monkeypatch.setattr( generator, @@ -661,7 +622,7 @@ def is_alive(self): lambda **kwargs: {"raw": True}, ) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.AdvancedChatAppGenerateResponseConverter.convert", + "services.workflow.execution.adapters.chatflow.app_generator.AdvancedChatAppGenerateResponseConverter.convert", lambda response, invoke_from: response, ) @@ -670,7 +631,6 @@ def is_alive(self): user=_make_account(account_id="user"), invoke_from=InvokeFrom.WEB_APP, application_generate_entity=application_generate_entity, - session=sqlite_session, workflow_execution_repository=SimpleNamespace(), workflow_node_execution_repository=SimpleNamespace(), conversation=conversation, @@ -680,16 +640,21 @@ def is_alive(self): assert response == {"raw": True} init_records.assert_not_called() - get_thread_messages_length.assert_called_once_with(conversation.id, session=sqlite_session) + get_thread_messages_length.assert_called_once_with(conversation.id) assert thread_data["started"] is True assert thread_data["joined"] is True assert thread_data["join_timeout"] == 300 assert commit_count == 0 def test_generate_worker_raises_when_workflow_not_found( - self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + self, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_engine: Engine, + *, + workflow_runtime: WorkflowRuntime, ): - generator = AdvancedChatAppGenerator() + generator = AdvancedChatAppGenerator(runtime=workflow_runtime) generator._dialogue_count = 1 app_config = self._build_app_config() @@ -707,39 +672,48 @@ def test_generate_worker_raises_when_workflow_not_found( workflow_run_id="run-id", ) - generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) - generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) - @contextmanager def _fake_context[**P](*args: P.args, **kwargs: P.kwargs): yield - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.preserve_flask_contexts", _fake_context) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=sqlite_engine, session=sqlite_session), + "services.workflow.execution.adapters.chatflow.app_generator.preserve_flask_contexts", _fake_context ) - with pytest.raises(ValueError, match="Workflow not found"): - generator._generate_worker( - flask_app=SimpleNamespace(), - application_generate_entity=application_generate_entity, - queue_manager=MagicMock(), - conversation_id="conv", - message_id="msg", - context=SimpleNamespace(), - variable_loader=SimpleNamespace(), - workflow_execution_repository=SimpleNamespace(), - workflow_node_execution_repository=SimpleNamespace(), - graph_engine_layers=(), - graph_runtime_state=None, - ) + sqlite_session.add_all( + [ + _make_app(), + _make_conversation(conversation_id="conv"), + _make_message(message_id="msg", conversation_id="conv"), + ] + ) + sqlite_session.commit() + queue = MagicMock() + generator._generate_worker( + system_user_id="session-id", + flask_app=SimpleNamespace(), + application_generate_entity=application_generate_entity, + queue_manager=queue, + conversation_id="conv", + message_id="msg", + context=SimpleNamespace(), + variable_loader=SimpleNamespace(), + workflow_execution_repository=SimpleNamespace(), + workflow_node_execution_repository=SimpleNamespace(), + graph_engine_layers=(), + graph_runtime_state=None, + ) + queue.publish_error.assert_called_once() def test_generate_worker_raises_when_app_not_found_for_internal_call( - self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + self, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_engine: Engine, + *, + workflow_runtime: WorkflowRuntime, ): - generator = AdvancedChatAppGenerator() + generator = AdvancedChatAppGenerator(runtime=workflow_runtime) generator._dialogue_count = 1 app_config = self._build_app_config() @@ -757,8 +731,6 @@ def test_generate_worker_raises_when_app_not_found_for_internal_call( workflow_run_id="run-id", ) - generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) - generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) sqlite_session.add(_make_workflow()) sqlite_session.commit() @@ -766,32 +738,39 @@ def test_generate_worker_raises_when_app_not_found_for_internal_call( def _fake_context[**P](*args: P.args, **kwargs: P.kwargs): yield - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.preserve_flask_contexts", _fake_context) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=sqlite_engine, session=sqlite_session), + "services.workflow.execution.adapters.chatflow.app_generator.preserve_flask_contexts", _fake_context ) - with pytest.raises(ValueError, match="App not found"): - generator._generate_worker( - flask_app=SimpleNamespace(), - application_generate_entity=application_generate_entity, - queue_manager=MagicMock(), - conversation_id="conv", - message_id="msg", - context=SimpleNamespace(), - variable_loader=SimpleNamespace(), - workflow_execution_repository=SimpleNamespace(), - workflow_node_execution_repository=SimpleNamespace(), - graph_engine_layers=(), - graph_runtime_state=None, - ) + queue = MagicMock() + generator._generate_worker( + system_user_id="session-id", + flask_app=SimpleNamespace(), + application_generate_entity=application_generate_entity, + queue_manager=queue, + conversation_id="conv", + message_id="msg", + context=SimpleNamespace(), + variable_loader=SimpleNamespace(), + workflow_execution_repository=SimpleNamespace(), + workflow_node_execution_repository=SimpleNamespace(), + graph_engine_layers=(), + graph_runtime_state=None, + ) + queue.publish_error.assert_called_once() def test_generate_worker_handles_stopped_error( - self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + self, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_engine: Engine, + *, + workflow_runtime: WorkflowRuntime, ): - generator = AdvancedChatAppGenerator() + from repositories.agent.runtime_repository import WorkflowAgentBindingResolver + + resolver = WorkflowAgentBindingResolver(sessionmaker(bind=sqlite_engine)) + generator = AdvancedChatAppGenerator(runtime=workflow_runtime) generator._dialogue_count = 1 app_config = self._build_app_config() @@ -810,34 +789,40 @@ def test_generate_worker_handles_stopped_error( ) queue_manager = MagicMock() - generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) - generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) @contextmanager def _fake_context[**P](*args: P.args, **kwargs: P.kwargs): yield - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.preserve_flask_contexts", _fake_context) + monkeypatch.setattr( + "services.workflow.execution.adapters.chatflow.app_generator.preserve_flask_contexts", _fake_context + ) - sqlite_session.add_all([_make_app(), _make_workflow()]) + sqlite_session.add_all( + [ + _make_app(), + _make_workflow(), + _make_conversation(conversation_id="conv"), + _make_message(message_id="msg", conversation_id="conv"), + ] + ) sqlite_session.commit() class _Runner: def __init__(self, **kwargs): - _ = kwargs + assert kwargs["runtime"] is workflow_runtime def run(self): raise GenerateTaskStoppedError() - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.AdvancedChatAppRunner", _Runner) - restore_workflow_run_graph = MagicMock() - monkeypatch.setattr(generator, "_restore_workflow_run_graph", restore_workflow_run_graph) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=sqlite_engine, session=sqlite_session), + "services.workflow.execution.adapters.chatflow.app_generator.AdvancedChatAppRunner", _Runner ) + restore_workflow_run_graph = MagicMock() + monkeypatch.setattr(generator._runtime.contexts, "restore_graph", restore_workflow_run_graph) generator._generate_worker( + system_user_id="session-id", flask_app=SimpleNamespace(), application_generate_entity=application_generate_entity, queue_manager=queue_manager, @@ -852,13 +837,18 @@ def run(self): ) queue_manager.publish_error.assert_not_called() - assert restore_workflow_run_graph.call_args.kwargs["workflow"].id == "workflow-id" - assert restore_workflow_run_graph.call_args.kwargs["workflow_run_id"] == "run-id" + assert restore_workflow_run_graph.call_args.args[0].id == "workflow-id" + assert restore_workflow_run_graph.call_args.args[1] == "run-id" def test_generate_worker_handles_validation_error( - self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + self, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_engine: Engine, + *, + workflow_runtime: WorkflowRuntime, ): - generator = AdvancedChatAppGenerator() + generator = AdvancedChatAppGenerator(runtime=workflow_runtime) generator._dialogue_count = 1 app_config = self._build_app_config() @@ -887,16 +877,23 @@ class _ValidationModel(BaseModel): raise AssertionError("validation error should be created") queue_manager = MagicMock() - generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) - generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) - sqlite_session.add_all([_make_app(), _make_workflow()]) + sqlite_session.add_all( + [ + _make_app(), + _make_workflow(), + _make_conversation(conversation_id="conv"), + _make_message(message_id="msg", conversation_id="conv"), + ] + ) sqlite_session.commit() @contextmanager def _fake_context[**P](*args: P.args, **kwargs: P.kwargs): yield - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.preserve_flask_contexts", _fake_context) + monkeypatch.setattr( + "services.workflow.execution.adapters.chatflow.app_generator.preserve_flask_contexts", _fake_context + ) class _Runner: def __init__(self, **kwargs): @@ -905,13 +902,12 @@ def __init__(self, **kwargs): def run(self): raise validation_error - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.AdvancedChatAppRunner", _Runner) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=sqlite_engine, session=sqlite_session), + "services.workflow.execution.adapters.chatflow.app_generator.AdvancedChatAppRunner", _Runner ) generator._generate_worker( + system_user_id="session-id", flask_app=SimpleNamespace(), application_generate_entity=application_generate_entity, queue_manager=queue_manager, @@ -928,10 +924,22 @@ def run(self): queue_manager.publish_error.assert_called_once() def test_generate_worker_handles_value_and_unknown_errors( - self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + self, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_engine: Engine, + *, + workflow_runtime: WorkflowRuntime, ): app_config = self._build_app_config() - sqlite_session.add_all([_make_app(), _make_workflow()]) + sqlite_session.add_all( + [ + _make_app(), + _make_workflow(), + _make_conversation(conversation_id="conv"), + _make_message(message_id="msg", conversation_id="conv"), + ] + ) sqlite_session.commit() @contextmanager @@ -949,7 +957,7 @@ def run(self): return _Runner for raised_error in [ValueError("bad input"), RuntimeError("unexpected")]: - generator = AdvancedChatAppGenerator() + generator = AdvancedChatAppGenerator(runtime=workflow_runtime) generator._dialogue_count = 1 application_generate_entity = AdvancedChatAppGenerateEntity.model_construct( task_id="task", @@ -966,21 +974,18 @@ def run(self): ) queue_manager = MagicMock() - generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) - generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.preserve_flask_contexts", _fake_context) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.AdvancedChatAppRunner", - _make_runner(raised_error), + "services.workflow.execution.adapters.chatflow.app_generator.preserve_flask_contexts", _fake_context ) - apply_config_overrides(monkeypatch, DEBUG=True) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=sqlite_engine, session=sqlite_session), + "services.workflow.execution.adapters.chatflow.app_generator.AdvancedChatAppRunner", + _make_runner(raised_error), ) + apply_config_overrides(monkeypatch, DEBUG=True) generator._generate_worker( + system_user_id="session-id", flask_app=SimpleNamespace(), application_generate_entity=application_generate_entity, queue_manager=queue_manager, @@ -996,8 +1001,10 @@ def run(self): queue_manager.publish_error.assert_called_once() - def test_handle_response_closed_file_raises_stopped(self, monkeypatch: pytest.MonkeyPatch): - generator = AdvancedChatAppGenerator() + def test_handle_response_closed_file_raises_stopped( + self, monkeypatch: pytest.MonkeyPatch, *, workflow_runtime: WorkflowRuntime + ): + generator = AdvancedChatAppGenerator(runtime=workflow_runtime) generator._dialogue_count = 1 app_config = WorkflowUIBasedAppConfig( @@ -1030,7 +1037,7 @@ def process(self): raise ValueError("I/O operation on closed file.") monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.AdvancedChatAppGenerateTaskPipeline", + "services.workflow.execution.adapters.chatflow.app_generator.AdvancedChatAppGenerateTaskPipeline", _Pipeline, ) @@ -1053,9 +1060,13 @@ def process(self): ) def test_handle_response_re_raises_value_error( - self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + self, + monkeypatch: pytest.MonkeyPatch, + caplog: pytest.LogCaptureFixture, + *, + workflow_runtime: WorkflowRuntime, ): - generator = AdvancedChatAppGenerator() + generator = AdvancedChatAppGenerator(runtime=workflow_runtime) generator._dialogue_count = 1 app_config = self._build_app_config() application_generate_entity = AdvancedChatAppGenerateEntity.model_construct( @@ -1079,9 +1090,11 @@ def __init__(self, **kwargs): def process(self): raise ValueError("other error") - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.AdvancedChatAppGenerateTaskPipeline", _Pipeline) + monkeypatch.setattr( + "services.workflow.execution.adapters.chatflow.app_generator.AdvancedChatAppGenerateTaskPipeline", _Pipeline + ) - with caplog.at_level(logging.ERROR, logger="core.app.apps.advanced_chat.app_generator"): + with caplog.at_level(logging.ERROR, logger="services.workflow.execution.adapters.chatflow.app_generator"): with pytest.raises(ValueError, match="other error"): generator._handle_advanced_chat_response( application_generate_entity=application_generate_entity, @@ -1103,9 +1116,14 @@ def process(self): assert "Failed to process generate task pipeline, conversation_id: conv" in caplog.messages def test_generate_worker_handles_invoke_auth_error( - self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_engine: Engine + self, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + sqlite_engine: Engine, + *, + workflow_runtime: WorkflowRuntime, ): - generator = AdvancedChatAppGenerator() + generator = AdvancedChatAppGenerator(runtime=workflow_runtime) generator._dialogue_count = 1 app_config = WorkflowUIBasedAppConfig( @@ -1132,8 +1150,6 @@ def test_generate_worker_handles_invoke_auth_error( queue_manager = MagicMock() - generator._get_conversation = MagicMock(return_value=_make_conversation(conversation_id="conv")) - generator._get_message = MagicMock(return_value=_make_message(message_id="msg", conversation_id="conv")) sqlite_session.add_all([_make_app(), _make_workflow(), _make_end_user()]) sqlite_session.commit() @@ -1146,20 +1162,20 @@ def run(self): raise InvokeAuthorizationError("bad key") - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.AdvancedChatAppRunner", _Runner) + monkeypatch.setattr( + "services.workflow.execution.adapters.chatflow.app_generator.AdvancedChatAppRunner", _Runner + ) @contextmanager def _fake_context[**P](*args: P.args, **kwargs: P.kwargs): yield - monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.preserve_flask_contexts", _fake_context) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=sqlite_engine, session=sqlite_session), + "services.workflow.execution.adapters.chatflow.app_generator.preserve_flask_contexts", _fake_context ) generator._generate_worker( + system_user_id="session-id", flask_app=SimpleNamespace(), application_generate_entity=application_generate_entity, queue_manager=queue_manager, @@ -1176,9 +1192,14 @@ def _fake_context[**P](*args: P.args, **kwargs: P.kwargs): assert queue_manager.publish_error.called def test_generate_debugger_enables_retrieve_source( - self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session, sqlite_engine: Engine + self, + monkeypatch: pytest.MonkeyPatch, + unbound_session: Session, + sqlite_engine: Engine, + *, + workflow_runtime: WorkflowRuntime, ): - generator = AdvancedChatAppGenerator() + generator = AdvancedChatAppGenerator(runtime=workflow_runtime) app_config = WorkflowUIBasedAppConfig( tenant_id="tenant", @@ -1190,11 +1211,11 @@ def test_generate_debugger_enables_retrieve_source( ) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.AdvancedChatAppConfigManager.get_app_config", + "services.workflow.execution.adapters.chatflow.app_generator.AdvancedChatAppConfigManager.get_app_config", lambda app_model, workflow: app_config, ) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.FileUploadConfigManager.convert", + "services.workflow.execution.adapters.chatflow.app_generator.FileUploadConfigManager.convert", lambda features_dict, is_vision=False: None, ) DummyTraceQueueManager = type( @@ -1207,21 +1228,9 @@ def test_generate_debugger_enables_retrieve_source( }, ) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.TraceQueueManager", + "services.workflow.execution.adapters.chatflow.app_generator.TraceQueueManager", DummyTraceQueueManager, ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.DifyCoreRepositoryFactory.create_workflow_execution_repository", - lambda **kwargs: SimpleNamespace(), - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", - lambda **kwargs: SimpleNamespace(), - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=sqlite_engine, session=unbound_session), - ) captured = {} @@ -1243,7 +1252,6 @@ def _fake_generate(**kwargs): invoke_from=InvokeFrom.DEBUGGER, workflow_run_id="run-id", streaming=False, - session=unbound_session, ) assert result == {"ok": True} @@ -1251,9 +1259,14 @@ def _fake_generate(**kwargs): assert captured["application_generate_entity"].query == "hello" def test_generate_service_api_sets_parent_message_id( - self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session, sqlite_engine: Engine + self, + monkeypatch: pytest.MonkeyPatch, + unbound_session: Session, + sqlite_engine: Engine, + *, + workflow_runtime: WorkflowRuntime, ): - generator = AdvancedChatAppGenerator() + generator = AdvancedChatAppGenerator(runtime=workflow_runtime) app_config = WorkflowUIBasedAppConfig( tenant_id="tenant", @@ -1265,11 +1278,11 @@ def test_generate_service_api_sets_parent_message_id( ) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.AdvancedChatAppConfigManager.get_app_config", + "services.workflow.execution.adapters.chatflow.app_generator.AdvancedChatAppConfigManager.get_app_config", lambda app_model, workflow: app_config, ) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.FileUploadConfigManager.convert", + "services.workflow.execution.adapters.chatflow.app_generator.FileUploadConfigManager.convert", lambda features_dict, is_vision=False: None, ) DummyTraceQueueManager = type( @@ -1282,21 +1295,9 @@ def test_generate_service_api_sets_parent_message_id( }, ) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.TraceQueueManager", + "services.workflow.execution.adapters.chatflow.app_generator.TraceQueueManager", DummyTraceQueueManager, ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.DifyCoreRepositoryFactory.create_workflow_execution_repository", - lambda **kwargs: SimpleNamespace(), - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", - lambda **kwargs: SimpleNamespace(), - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.db", - SimpleNamespace(engine=sqlite_engine, session=unbound_session), - ) captured = {} @@ -1318,7 +1319,6 @@ def _fake_generate(**kwargs): invoke_from=InvokeFrom.SERVICE_API, workflow_run_id="run-id", streaming=False, - session=unbound_session, ) assert captured["application_generate_entity"].parent_message_id == UUID_NIL @@ -1337,9 +1337,13 @@ def _build_app_config() -> WorkflowUIBasedAppConfig: ) def test_resume_restores_trace_manager_when_missing( - self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session + self, + monkeypatch: pytest.MonkeyPatch, + unbound_session: Session, + *, + workflow_runtime: WorkflowRuntime, ): - generator = AdvancedChatAppGenerator() + generator = AdvancedChatAppGenerator(runtime=workflow_runtime) application_generate_entity = AdvancedChatAppGenerateEntity.model_construct( task_id="task", app_config=self._build_app_config(), @@ -1366,7 +1370,7 @@ def test_resume_restores_trace_manager_when_missing( }, ) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.TraceQueueManager", + "services.workflow.execution.adapters.chatflow.app_generator.TraceQueueManager", DummyTraceQueueManager, ) captured_entity: AdvancedChatAppGenerateEntity | None = None @@ -1384,7 +1388,6 @@ def _fake_generate(**kwargs): user=_make_end_user(), conversation=_make_conversation(), message=_make_message(), - session=unbound_session, application_generate_entity=application_generate_entity, workflow_execution_repository=SimpleNamespace(), workflow_node_execution_repository=SimpleNamespace(), @@ -1398,8 +1401,14 @@ def _fake_generate(**kwargs): assert trace_manager.app_id == "app-id" assert trace_manager.user_id == "session-id" - def test_resume_preserves_existing_trace_manager(self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session): - generator = AdvancedChatAppGenerator() + def test_resume_preserves_existing_trace_manager( + self, + monkeypatch: pytest.MonkeyPatch, + unbound_session: Session, + *, + workflow_runtime: WorkflowRuntime, + ): + generator = AdvancedChatAppGenerator(runtime=workflow_runtime) existing_trace_manager = SimpleNamespace(app_id="existing-app", user_id="existing-user") application_generate_entity = AdvancedChatAppGenerateEntity.model_construct( task_id="task", @@ -1432,7 +1441,6 @@ def _fake_generate(**kwargs): user=_make_end_user(), conversation=_make_conversation(), message=_make_message(), - session=unbound_session, application_generate_entity=application_generate_entity, workflow_execution_repository=SimpleNamespace(), workflow_node_execution_repository=SimpleNamespace(), diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py index 5ed29a74063212..0ac5f93be4a809 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_conversation_variables.py @@ -1,151 +1,60 @@ -"""SQLite-backed conversation-variable synchronization tests for AdvancedChatAppRunner.""" - -from unittest.mock import MagicMock +"""Conversation defaults are initialized once in the repository's own transaction.""" import pytest from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker -from core.app.apps.advanced_chat import app_runner as app_runner_module -from core.app.apps.advanced_chat.app_runner import AdvancedChatAppRunner from factories import variable_factory -from graphon.variables import SegmentType -from models import App, Conversation, ConversationVariable, Message -from models.workflow import Workflow, WorkflowType +from models import Conversation, ConversationVariable +from repositories.workflow.conversation_variable_repository import WorkflowConversationVariableRepository APP_ID = "11111111-1111-1111-1111-111111111111" CONVERSATION_ID = "22222222-2222-2222-2222-222222222222" -OTHER_CONVERSATION_ID = "22222222-2222-2222-2222-222222222223" -VAR_1_ID = "33333333-3333-3333-3333-333333333333" -VAR_2_ID = "33333333-3333-3333-3333-333333333334" -def _variable(variable_id: str, name: str, value: str): +def variable(identifier: str, name: str, value: str): return variable_factory.build_conversation_variable_from_mapping( - { - "id": variable_id, - "name": name, - "value_type": SegmentType.STRING, - "value": value, - } + {"id": identifier, "name": name, "value_type": "string", "value": value} ) -def _runner(workflow_variables: list[object]) -> AdvancedChatAppRunner: - workflow = Workflow( - id="55555555-5555-5555-5555-555555555555", - tenant_id="66666666-6666-6666-6666-666666666666", - app_id=APP_ID, - type=WorkflowType.CHAT, - version=Workflow.VERSION_DRAFT, - graph="{}", - features="{}", - created_by="44444444-4444-4444-4444-444444444444", - ) - workflow.conversation_variables = workflow_variables - conversation = Conversation(id=CONVERSATION_ID, app_id=APP_ID) - return AdvancedChatAppRunner( - application_generate_entity=MagicMock(), - queue_manager=MagicMock(), - conversation=conversation, - message=Message(id="77777777-7777-7777-7777-777777777777"), - dialogue_count=1, - variable_loader=MagicMock(), - workflow=workflow, - system_user_id="44444444-4444-4444-4444-444444444444", - app=App(id=APP_ID, tenant_id=workflow.tenant_id), - workflow_execution_repository=MagicMock(), - workflow_node_execution_repository=MagicMock(), - ) - - -def _bind_runner_sessions(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: - engine = sqlite_session.get_bind() - monkeypatch.setattr( - app_runner_module, - "create_session", - lambda: Session(engine, expire_on_commit=False), +@pytest.mark.parametrize("existing", [0, 1, 2]) +def test_initialize_preserves_existing_values_and_adds_missing_defaults( + sqlite_session: Session, sqlite_session_factory: sessionmaker[Session], existing: int +): + defaults = [ + variable("33333333-3333-3333-3333-333333333333", "first", "default"), + variable("33333333-3333-3333-3333-333333333334", "second", "default"), + ] + sqlite_session.add( + Conversation(id=CONVERSATION_ID, app_id=APP_ID, mode="advanced-chat", name="test", inputs={}, from_source="api") ) - - -def _persist_variable(session: Session, *, variable: object, conversation_id: str = CONVERSATION_ID) -> None: - session.add( - ConversationVariable.from_variable( - app_id=APP_ID, - conversation_id=conversation_id, - variable=variable, + for item in defaults[:existing]: + sqlite_session.add( + ConversationVariable.from_variable( + app_id=APP_ID, conversation_id=CONVERSATION_ID, variable=variable(item.id, item.name, "edited") + ) ) + sqlite_session.commit() + repository = WorkflowConversationVariableRepository(sqlite_session_factory) + result = repository.initialize(app_id=APP_ID, conversation_id=CONVERSATION_ID, defaults=defaults) + assert {item.id: item.value for item in result} == { + item.id: "edited" if i < existing else "default" for i, item in enumerate(defaults) + } + again = repository.initialize(app_id=APP_ID, conversation_id=CONVERSATION_ID, defaults=defaults) + assert {item.id: item.value for item in again} == {item.id: item.value for item in result} + assert len(sqlite_session.scalars(select(ConversationVariable)).all()) == 2 + + +def test_non_uuid_variable_is_created_once(sqlite_session: Session, sqlite_session_factory: sessionmaker[Session]): + item = variable("opt-comp-prompt-var", "comparison_prompt", "-") + sqlite_session.add( + Conversation(id=CONVERSATION_ID, app_id=APP_ID, mode="advanced-chat", name="test", inputs={}, from_source="api") ) - session.commit() - - -@pytest.mark.parametrize("sqlite_session", [(ConversationVariable,)], indirect=True) -def test_missing_conversation_variables_are_added(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: - existing_variable = _variable(VAR_1_ID, "existing_var", "default1") - new_variable = _variable(VAR_2_ID, "new_var", "default2") - _persist_variable(sqlite_session, variable=existing_variable) - _persist_variable(sqlite_session, variable=new_variable, conversation_id=OTHER_CONVERSATION_ID) - _bind_runner_sessions(monkeypatch, sqlite_session) - - variables = _runner([existing_variable, new_variable])._initialize_conversation_variables() - - assert [variable.id for variable in variables] == [VAR_1_ID, VAR_2_ID] - persisted = sqlite_session.scalars( - select(ConversationVariable) - .where(ConversationVariable.conversation_id == CONVERSATION_ID) - .order_by(ConversationVariable.id) - ).all() - assert [variable.id for variable in persisted] == [VAR_1_ID, VAR_2_ID] - - -@pytest.mark.parametrize("sqlite_session", [(ConversationVariable,)], indirect=True) -def test_no_variables_creates_all(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: - workflow_variables = [ - _variable(VAR_1_ID, "var1", "default1"), - _variable(VAR_2_ID, "var2", "default2"), - ] - _bind_runner_sessions(monkeypatch, sqlite_session) - - variables = _runner(workflow_variables)._initialize_conversation_variables() - - assert [variable.id for variable in variables] == [VAR_1_ID, VAR_2_ID] - persisted = sqlite_session.scalars(select(ConversationVariable).order_by(ConversationVariable.id)).all() - assert [variable.id for variable in persisted] == [VAR_1_ID, VAR_2_ID] - - -@pytest.mark.parametrize("sqlite_session", [(ConversationVariable,)], indirect=True) -def test_all_variables_exist_no_changes(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: - workflow_variables = [ - _variable(VAR_1_ID, "var1", "default1"), - _variable(VAR_2_ID, "var2", "default2"), - ] - for variable in workflow_variables: - _persist_variable(sqlite_session, variable=variable) - _bind_runner_sessions(monkeypatch, sqlite_session) - - variables = _runner(workflow_variables)._initialize_conversation_variables() - - assert [variable.id for variable in variables] == [VAR_1_ID, VAR_2_ID] - persisted = sqlite_session.scalars(select(ConversationVariable)).all() - assert len(persisted) == 2 - - -@pytest.mark.parametrize("sqlite_session", [(ConversationVariable,)], indirect=True) -def test_non_uuid_conversation_variable_is_created_once( - monkeypatch: pytest.MonkeyPatch, sqlite_session: Session -) -> None: - author_id = "opt-comp-prompt-var" - variable = _variable(author_id, "optimization_comparison_prompt", "-") - _bind_runner_sessions(monkeypatch, sqlite_session) - runner = _runner([variable]) - - first = runner._initialize_conversation_variables() - second = runner._initialize_conversation_variables() - - expected_row_id = ConversationVariable.storage_id(variable) - assert variable.id == author_id - assert [item.id for item in first] == [author_id] - assert [item.id for item in second] == [author_id] - persisted = sqlite_session.scalars(select(ConversationVariable)).all() - assert [row.id for row in persisted] == [expected_row_id] - assert persisted[0].to_variable().id == author_id + sqlite_session.commit() + repository = WorkflowConversationVariableRepository(sqlite_session_factory) + for _ in range(2): + result = repository.initialize(app_id=APP_ID, conversation_id=CONVERSATION_ID, defaults=[item]) + assert result[0].id == item.id + stored = sqlite_session.scalars(select(ConversationVariable)).all() + assert [row.id for row in stored] == [ConversationVariable.storage_id(item)] diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py index bb1c2cf8573e63..1a6b5ea38adb25 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py @@ -1,20 +1,17 @@ import json -from contextlib import contextmanager from unittest.mock import MagicMock, patch from uuid import uuid4 import pytest -from sqlalchemy import event -from sqlalchemy.engine import Engine -from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.orm import Session -import core.app.apps.advanced_chat.app_runner as module -from core.app.apps.advanced_chat.app_runner import AdvancedChatAppRunner from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom -from core.app.entities.queue_entities import QueueAnnotationReplyEvent, QueueStopEvent +from core.app.entities.queue_entities import QueueStopEvent from core.moderation.base import ModerationError -from models.model import App, AppMode, Conversation, IconType, Message, MessageAnnotation +from models.model import App, AppMode, Conversation, IconType, Message from models.workflow import Workflow, WorkflowType +from services.workflow.execution.adapters.chatflow.app_runner import AdvancedChatAppRunner +from services.workflow.execution.ports import WorkflowRuntime MINIMAL_GRAPH = { "nodes": [ @@ -31,7 +28,7 @@ @pytest.fixture -def build_runner(sqlite_session: Session): +def build_runner(sqlite_session: Session, *, workflow_runtime: WorkflowRuntime): """Construct a minimal AdvancedChatAppRunner with heavy dependencies mocked.""" app_id = str(uuid4()) workflow_id = str(uuid4()) @@ -40,7 +37,9 @@ def build_runner(sqlite_session: Session): # Mocks for constructor args mock_queue_manager = MagicMock() - conversation = Conversation(id=str(uuid4()), app_id=app_id) + conversation = Conversation( + id=str(uuid4()), app_id=app_id, mode=AppMode.ADVANCED_CHAT, name="Test", inputs={}, from_source="api" + ) message = Message(id=str(uuid4()), app_id=app_id, conversation_id=conversation.id) workflow = Workflow( id=workflow_id, @@ -69,7 +68,7 @@ def build_runner(sqlite_session: Session): enable_site=False, enable_api=False, ) - sqlite_session.add(app) + sqlite_session.add_all([app, conversation]) sqlite_session.commit() gen = MagicMock(spec=AdvancedChatAppGenerateEntity) @@ -99,6 +98,7 @@ def build_runner(sqlite_session: Session): app=app, workflow_execution_repository=MagicMock(), workflow_node_execution_repository=MagicMock(), + runtime=workflow_runtime, ) return runner @@ -107,7 +107,7 @@ def build_runner(sqlite_session: Session): def _patch_common_run_deps(runner: AdvancedChatAppRunner): """Context manager that patches common heavy deps used by run().""" return patch.multiple( - "core.app.apps.advanced_chat.app_runner", + "services.workflow.execution.adapters.chatflow.app_runner", RedisChannel=MagicMock(), redis_client=MagicMock(), WorkflowEntry=MagicMock(**{"return_value.run.return_value": iter([])}), @@ -155,7 +155,7 @@ def test_run_applies_overridden_inputs_and_query_from_moderation(build_runner): return_value=(True, overridden_inputs, overridden_query), ) as mock_moderate, patch.object(runner, "handle_annotation_reply", return_value=False) as mock_anno, - patch.object(runner, "_init_graph", return_value=MagicMock()) as mock_init_graph, + patch.object(runner._graphs, "build", return_value=MagicMock()) as mock_init_graph, ): runner.run() @@ -185,7 +185,7 @@ def test_run_returns_early_when_direct_output_via_handle_input_moderation(build_ "handle_input_moderation", return_value=(True, runner.application_generate_entity.inputs, runner.application_generate_entity.query), ) as mock_handle, - patch.object(runner, "_init_graph") as mock_init_graph, + patch.object(runner._graphs, "build") as mock_init_graph, patch.object(runner, "handle_annotation_reply") as mock_anno, ): runner.run() @@ -194,77 +194,3 @@ def test_run_returns_early_when_direct_output_via_handle_input_moderation(build_ # Ensure no further steps executed mock_anno.assert_not_called() mock_init_graph.assert_not_called() - - -def test_run_publishes_annotation_after_commit(build_runner, sqlite_engine: Engine): - runner = build_runner - events: list[str] = [] - - def record_commit(session: Session) -> None: - if session.get_bind() is sqlite_engine: - events.append("commit") - - event.listen(Session, "after_commit", record_commit) - annotation_reply = MessageAnnotation( - app_id=runner._app.id, - question="question", - content="annotated answer", - account_id=str(uuid4()), - ) - - def publish(event): - if isinstance(event, QueueAnnotationReplyEvent): - events.append("publish") - - with ( - _patch_common_run_deps(runner), - patch.object( - runner, - "handle_input_moderation", - return_value=(False, runner.application_generate_entity.inputs, runner.application_generate_entity.query), - ), - patch.object(runner, "handle_annotation_reply", return_value=annotation_reply), - patch.object(runner, "_publish_event", side_effect=publish), - patch.object(runner, "_complete_with_stream_output"), - ): - runner.run() - event.remove(Session, "after_commit", record_commit) - - assert events == ["commit", "publish"] - - -def test_run_closes_scoped_session_before_workflow_run(build_runner, sqlite_session_factory: sessionmaker[Session]): - runner = build_runner - events = [] - - @contextmanager - def observed_session(): - with sqlite_session_factory() as session: - yield session - events.append("close") - - workflow_entry = MagicMock() - - def run_workflow(): - events.append("run") - return iter([]) - - workflow_entry.run.side_effect = run_workflow - - with ( - patch.object(module, "create_session", observed_session), - patch.object(module, "RedisChannel"), - patch.object(module, "redis_client"), - patch.object(module, "WorkflowEntry", return_value=workflow_entry), - patch.object( - runner, - "handle_input_moderation", - return_value=(False, runner.application_generate_entity.inputs, runner.application_generate_entity.query), - ), - patch.object(runner, "handle_annotation_reply", return_value=False), - patch.object(runner, "_initialize_conversation_variables", return_value=[]), - patch.object(runner, "_init_graph", return_value=MagicMock()), - ): - runner.run() - - assert events[-2:] == ["close", "run"] diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline.py index 5166d33fbb78a8..6868a3657bbfbe 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline.py @@ -1,409 +1,329 @@ from __future__ import annotations -from contextlib import nullcontext -from datetime import datetime +from dataclasses import dataclass, replace +from datetime import timedelta from types import SimpleNamespace from unittest import mock import pytest -from sqlalchemy import select -from sqlalchemy.orm import Session +from sqlalchemy import create_engine, event, select +from sqlalchemy.orm import Session, sessionmaker -from core.app.apps.advanced_chat import generate_task_pipeline as pipeline_module -from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig +from core.app.apps.draft_variable_saver import NoopDraftVariableSaver +from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager +from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom from core.app.entities.queue_entities import ( + QueueErrorEvent, QueuePingEvent, QueueTextChunkEvent, QueueWorkflowPartialSuccessEvent, QueueWorkflowPausedEvent, + QueueWorkflowStartedEvent, QueueWorkflowSucceededEvent, ) from core.app.entities.task_entities import StreamEvent from core.workflow.nodes.human_input.pause_reason import HumanInputRequired +from core.workflow.system_variables import build_system_variables from graphon.enums import WorkflowExecutionStatus +from graphon.runtime import GraphRuntimeState, VariablePool +from libs.datetime_utils import naive_utc_now +from models.base import TypeBase from models.enums import MessageStatus from models.execution_extra_content import HumanInputContent -from models.model import AppMode, EndUser, Message - - -def _build_pipeline() -> pipeline_module.AdvancedChatAppGenerateTaskPipeline: - pipeline = pipeline_module.AdvancedChatAppGenerateTaskPipeline.__new__( - pipeline_module.AdvancedChatAppGenerateTaskPipeline - ) - pipeline._workflow_run_id = "run-1" - pipeline._message_id = "message-1" - pipeline._workflow_tenant_id = "tenant-1" - return pipeline - - -def test_process_passes_message_id_to_conversation_name_generation() -> None: - pipeline = _build_pipeline() - pipeline._conversation_id = "conversation-1" - pipeline._application_generate_entity = SimpleNamespace(query="hello", trace_manager=None) - pipeline._message_cycle_manager = mock.Mock() - pipeline._base_task_pipeline = SimpleNamespace(stream=True) - pipeline._wrapper_process_stream_response = mock.Mock(return_value=iter(())) - pipeline._to_stream_response = mock.Mock(return_value="stream") - - result = pipeline.process() - - assert result == "stream" - pipeline._message_cycle_manager.generate_conversation_name.assert_called_once_with( - conversation_id="conversation-1", query="hello", message_id="message-1" - ) - - -def _message(*, status: MessageStatus, answer: str = "") -> Message: - return Message( - id="message-1", - status=status, - answer=answer, - invoke_from=InvokeFrom.WEB_APP, - from_end_user_id="user-1", +from models.human_input import HumanInputForm, HumanInputFormRecipient +from models.model import App, AppMode, Conversation, Message, MessageFile +from repositories.app.generation_repository import AppGenerationRepository +from repositories.workflow.runtime_context_repository import WorkflowRuntimeContextRepository +from services.app.generation.ports import ChatRecordSeed, ConversationSnapshot, MessageSnapshot, WorkflowSnapshot +from services.errors.message import MessageNotExistsError +from services.workflow.execution.adapters.chatflow import generate_task_pipeline as pipeline_module +from tests.unit_tests.model_factories import make_app, make_end_user + + +@dataclass +class MessageCase: + pipeline: pipeline_module.AdvancedChatAppGenerateTaskPipeline + sessions: sessionmaker[Session] + + def message(self) -> Message: + with self.sessions() as session: + return session.get(Message, "message-1") + + +@pytest.fixture +def message_store(): + # Keep the injected message database separate from the globally configured test database. + engine = create_engine("sqlite://") + TypeBase.metadata.create_all( + engine, + tables=[ + model.__table__ + for model in ( + App, + Conversation, + Message, + MessageFile, + HumanInputForm, + HumanInputFormRecipient, + HumanInputContent, + ) + ], ) - - -def test_persist_human_input_extra_content_adds_record( - monkeypatch: pytest.MonkeyPatch, sqlite_session: Session -) -> None: - pipeline = _build_pipeline() - monkeypatch.setattr(pipeline, "_load_human_input_form_id", lambda **kwargs: "form-1") - - pipeline._persist_human_input_extra_content(node_id="node-1") - - content = sqlite_session.scalar(select(HumanInputContent)) - assert content is not None - assert content.workflow_run_id == "run-1" - assert content.message_id == "message-1" - assert content.form_id == "form-1" - - -def test_persist_human_input_extra_content_skips_when_form_missing( - monkeypatch: pytest.MonkeyPatch, sqlite_session: Session -) -> None: - pipeline = _build_pipeline() - monkeypatch.setattr(pipeline, "_load_human_input_form_id", lambda **kwargs: None) - - pipeline._persist_human_input_extra_content(node_id="node-1") - - assert sqlite_session.scalar(select(HumanInputContent)) is None - - -def test_persist_human_input_extra_content_skips_when_existing( - monkeypatch: pytest.MonkeyPatch, sqlite_session: Session -) -> None: - pipeline = _build_pipeline() - monkeypatch.setattr(pipeline, "_load_human_input_form_id", lambda **kwargs: "form-1") - existing = HumanInputContent.new(workflow_run_id="run-1", message_id="message-1", form_id="form-1") - sqlite_session.add(existing) - sqlite_session.commit() - - pipeline._persist_human_input_extra_content(node_id="node-1") - - contents = list(sqlite_session.scalars(select(HumanInputContent))) - assert [content.id for content in contents] == [existing.id] - - -def test_handle_workflow_paused_event_persists_human_input_extra_content(unbound_session: Session) -> None: - pipeline = _build_pipeline() - pipeline._application_generate_entity = SimpleNamespace(task_id="task-1") - pipeline._workflow_response_converter = mock.Mock() - pipeline._workflow_response_converter.workflow_pause_to_stream_response.return_value = [] - pipeline._ensure_graph_runtime_initialized = mock.Mock( - return_value=SimpleNamespace( - total_tokens=0, - node_run_steps=0, + try: + yield sessionmaker(bind=engine, expire_on_commit=False) + finally: + engine.dispose() + + +@pytest.fixture +def app_records(message_store): + return AppGenerationRepository(message_store) + + +@pytest.fixture +def message_case(app_records, message_store, *, tool_providers) -> MessageCase: + with message_store.begin() as session: + session.add(make_app(mode=AppMode.ADVANCED_CHAT)) + session.add( + HumanInputForm( + id="form-1", + tenant_id="tenant-1", + app_id="app-1", + workflow_run_id="run-1", + node_id="node-1", + form_definition="{}", + rendered_content="Approve?", + expiration_time=naive_utc_now() + timedelta(days=1), + ) + ) + conversation, message = app_records.initialize( + tenant_id="tenant-1", + app_id="app-1", + conversation_id=None, + seed=ChatRecordSeed( + conversation={ + "id": "conversation-1", + "mode": AppMode.ADVANCED_CHAT, + "name": "New conversation", + "inputs": {}, + "from_source": "api", + "from_end_user_id": "user-1", + "from_account_id": None, + }, + message={ + "id": "message-1", + "inputs": {}, + "query": "hello", + "message": "", + "answer": "", + "message_unit_price": 0, + "answer_unit_price": 0, + "currency": "USD", + "from_source": "api", + "from_end_user_id": "user-1", + "invoke_from": InvokeFrom.WEB_APP, + }, + files=[], ), ) - pipeline._save_message = mock.Mock() - message = _message(status=MessageStatus.NORMAL) - pipeline._get_message = mock.Mock(return_value=message) - pipeline._persist_human_input_extra_content = mock.Mock() - pipeline._base_task_pipeline = mock.Mock() - pipeline._base_task_pipeline.queue_manager = mock.Mock() - pipeline._message_saved_on_pause = False - - pipeline._database_session = lambda: nullcontext(unbound_session) # type: ignore[method-assign] - - reason = HumanInputRequired( - form_id="form-1", - form_content="content", - inputs=[], - actions=[], - node_id="node-1", - node_title="Approval", - resolved_default_values={}, + config = WorkflowUIBasedAppConfig( + tenant_id="tenant-1", + app_id="app-1", + app_mode=AppMode.ADVANCED_CHAT, + workflow_id="workflow-1", + variables=[], + additional_features=AppAdditionalFeatures(), ) - event = QueueWorkflowPausedEvent(reasons=[reason], outputs={}, paused_nodes=["node-1"]) - - list(pipeline._handle_workflow_paused_event(event)) - - pipeline._persist_human_input_extra_content.assert_called_once_with(form_id="form-1", node_id="node-1") - assert message.status == MessageStatus.PAUSED - - -def test_resume_appends_chunks_to_paused_answer(unbound_session: Session) -> None: - app_config = SimpleNamespace(app_id="app-1", tenant_id="tenant-1", sensitive_word_avoidance=None) - application_generate_entity = SimpleNamespace( - app_config=app_config, - files=[], - workflow_run_id="run-1", - query="hello", - invoke_from=InvokeFrom.WEB_APP, - inputs={}, + entity = AdvancedChatAppGenerateEntity( task_id="task-1", - trace_manager=None, - extras={}, - ) - queue_manager = SimpleNamespace(graph_runtime_state=None) - conversation = pipeline_module.ConversationSnapshot(id="conversation-1", mode=AppMode.ADVANCED_CHAT) - message = pipeline_module.MessageSnapshot( - id="message-1", - created_at=datetime(2024, 1, 1), - query="hello", - answer="before", - status=MessageStatus.PAUSED, - ) - user = EndUser() - user.id = "user-1" - user.session_id = "session-1" - workflow = pipeline_module.WorkflowSnapshot(id="workflow-1", tenant_id="tenant-1", features_dict={}) - - pipeline = pipeline_module.AdvancedChatAppGenerateTaskPipeline( - application_generate_entity=application_generate_entity, - workflow=workflow, - queue_manager=queue_manager, - conversation=conversation, - message=message, - user=user, - stream=True, - dialogue_count=1, - draft_var_saver_factory=SimpleNamespace(), - ) - - stored_message = _message(status=MessageStatus.PAUSED, answer="before") - pipeline._get_message = mock.Mock(return_value=stored_message) - pipeline._recorded_files = [] - - list(pipeline._handle_text_chunk_event(QueueTextChunkEvent(text="after"))) - pipeline._save_message(session=unbound_session) - - assert stored_message.answer == "beforeafter" - assert stored_message.status == MessageStatus.NORMAL - - -def test_emit_message_trace_enqueues_message_trace() -> None: - """When trace_manager is provided, _emit_message_trace must enqueue a MESSAGE_TRACE task.""" - from core.ops.entities.trace_entity import TraceTaskName - - mock_trace_manager = mock.MagicMock() - app_config = SimpleNamespace(app_id="app-1", tenant_id="tenant-1", sensitive_word_avoidance=None) - application_generate_entity = SimpleNamespace( - app_config=app_config, - files=[], - workflow_run_id="run-1", - query="hello", - invoke_from=InvokeFrom.WEB_APP, + app_config=config, inputs={}, - task_id="task-1", - trace_manager=mock_trace_manager, - extras={"trace_session_id": "session-trace-1"}, - ) - queue_manager = SimpleNamespace(graph_runtime_state=None) - conversation = pipeline_module.ConversationSnapshot(id="conv-1", mode=AppMode.ADVANCED_CHAT) - message = pipeline_module.MessageSnapshot( - id="msg-1", - created_at=datetime(2024, 1, 1), query="hello", - answer="", - status=MessageStatus.NORMAL, - ) - user = EndUser() - user.id = "user-1" - user.session_id = "session-1" - workflow = pipeline_module.WorkflowSnapshot(id="workflow-1", tenant_id="tenant-1", features_dict={}) - - pipeline = pipeline_module.AdvancedChatAppGenerateTaskPipeline( - application_generate_entity=application_generate_entity, - workflow=workflow, - queue_manager=queue_manager, - conversation=conversation, - message=message, - user=user, + files=[], + user_id="user-1", stream=True, - dialogue_count=1, - draft_var_saver_factory=SimpleNamespace(), - ) - - stored_message = SimpleNamespace( - id="msg-1", - answer="", - status=MessageStatus.NORMAL, - updated_at=None, - provider_response_latency=0, - message_tokens=0, - message_unit_price=0, - message_price_unit=0, - answer_tokens=0, - answer_unit_price=0, - answer_price_unit=0, - total_price=0, - currency="USD", - message_metadata=None, invoke_from=InvokeFrom.WEB_APP, - from_account_id=None, - from_end_user_id="user-1", - ) - pipeline._get_message = mock.Mock(return_value=stored_message) - pipeline._recorded_files = [] - - pipeline._emit_message_trace() - - mock_trace_manager.add_trace_task.assert_called_once() - trace_task = mock_trace_manager.add_trace_task.call_args[0][0] - assert trace_task.trace_type == TraceTaskName.MESSAGE_TRACE - assert trace_task.conversation_id == "conv-1" - assert trace_task.message_id == "msg-1" - assert trace_task.kwargs.get("trace_session_id") == "session-trace-1" - - -def test_save_message_does_not_emit_trace() -> None: - """_save_message must not enqueue a trace; emitting is the terminal caller's responsibility. - - This keeps intermediate saves (e.g. on pause) from duplicating message, token, and TTFT metrics - when the message is later resumed. - """ - mock_trace_manager = mock.MagicMock() - app_config = SimpleNamespace(app_id="app-1", tenant_id="tenant-1", sensitive_word_avoidance=None) - application_generate_entity = SimpleNamespace( - app_config=app_config, - files=[], workflow_run_id="run-1", - query="hello", - invoke_from=InvokeFrom.WEB_APP, - inputs={}, - task_id="task-1", - trace_manager=mock_trace_manager, - extras={"trace_session_id": "session-trace-1"}, + extras={"trace_session_id": "trace-1"}, ) - queue_manager = SimpleNamespace(graph_runtime_state=None) - conversation = pipeline_module.ConversationSnapshot(id="conv-1", mode=AppMode.ADVANCED_CHAT) - message = pipeline_module.MessageSnapshot( - id="msg-1", - created_at=datetime(2024, 1, 1), - query="hello", - answer="", - status=MessageStatus.NORMAL, + queue = MessageBasedAppQueueManager( + task_id=entity.task_id, + user_id=entity.user_id, + invoke_from=entity.invoke_from, + conversation_id=conversation.id, + app_mode=AppMode.ADVANCED_CHAT, + message_id=message.id, ) - user = EndUser() - user.id = "user-1" - user.session_id = "session-1" - workflow = pipeline_module.WorkflowSnapshot(id="workflow-1", tenant_id="tenant-1", features_dict={}) - pipeline = pipeline_module.AdvancedChatAppGenerateTaskPipeline( - application_generate_entity=application_generate_entity, - workflow=workflow, - queue_manager=queue_manager, - conversation=conversation, - message=message, - user=user, + contexts=WorkflowRuntimeContextRepository(message_store), + application_generate_entity=entity, + workflow=WorkflowSnapshot("workflow-1", "tenant-1", {}), + queue_manager=queue, + conversation=ConversationSnapshot.from_conversation(conversation), + message=MessageSnapshot.from_message(message), + user=make_end_user(end_user_id="user-1"), stream=True, - dialogue_count=1, - draft_var_saver_factory=SimpleNamespace(), + dialogue_count=app_records.dialogue_count(conversation.id), + draft_var_saver_factory=lambda *_args, **_kwargs: NoopDraftVariableSaver(), + chat_records=app_records, + tool_providers=tool_providers, ) - - stored_message = SimpleNamespace( - id="msg-1", - answer="", - status=MessageStatus.NORMAL, - updated_at=None, - provider_response_latency=0, - message_tokens=0, - message_unit_price=0, - message_price_unit=0, - answer_tokens=0, - answer_unit_price=0, - answer_price_unit=0, - total_price=0, - currency="USD", - message_metadata=None, - invoke_from=InvokeFrom.WEB_APP, - from_account_id=None, - from_end_user_id="user-1", + pipeline._graph_runtime_state = GraphRuntimeState( + variable_pool=VariablePool.from_bootstrap( + system_variables=build_system_variables(workflow_execution_id="run-1") + ), + start_at=0, ) - pipeline._get_message = mock.Mock(return_value=stored_message) - pipeline._recorded_files = [] + pipeline._workflow_run_id = "run-1" + return MessageCase(pipeline, message_store) - pipeline._save_message(session=mock.Mock()) - mock_trace_manager.add_trace_task.assert_not_called() +def _build_pipeline() -> pipeline_module.AdvancedChatAppGenerateTaskPipeline: + pipeline = pipeline_module.AdvancedChatAppGenerateTaskPipeline.__new__( + pipeline_module.AdvancedChatAppGenerateTaskPipeline + ) + pipeline._workflow_run_id = "run-1" + pipeline._message_id = "message-1" + pipeline._workflow_tenant_id = "tenant-1" + return pipeline -def test_emit_message_trace_skips_when_no_trace_manager() -> None: - """When trace_manager is None, _emit_message_trace and _save_message must not fail.""" - app_config = SimpleNamespace(app_id="app-1", tenant_id="tenant-1", sensitive_word_avoidance=None) - application_generate_entity = SimpleNamespace( - app_config=app_config, - files=[], - workflow_run_id="run-1", - query="hello", - invoke_from=InvokeFrom.WEB_APP, - inputs={}, - task_id="task-1", - trace_manager=None, - extras={}, - ) - queue_manager = SimpleNamespace(graph_runtime_state=None) - conversation = pipeline_module.ConversationSnapshot(id="conv-1", mode=AppMode.ADVANCED_CHAT) - message = pipeline_module.MessageSnapshot( - id="msg-1", - created_at=datetime(2024, 1, 1), - query="hello", - answer="", - status=MessageStatus.NORMAL, +def test_process_passes_message_id_to_conversation_name_generation(message_case, monkeypatch): + pipeline = message_case.pipeline + calls = [] + monkeypatch.setattr( + pipeline._message_cycle_manager, "generate_conversation_name", lambda **kwargs: calls.append(kwargs) ) - user = EndUser() - user.id = "user-1" - user.session_id = "session-1" - workflow = pipeline_module.WorkflowSnapshot(id="workflow-1", tenant_id="tenant-1", features_dict={}) - - pipeline = pipeline_module.AdvancedChatAppGenerateTaskPipeline( - application_generate_entity=application_generate_entity, - workflow=workflow, - queue_manager=queue_manager, - conversation=conversation, - message=message, - user=user, - stream=True, - dialogue_count=1, - draft_var_saver_factory=SimpleNamespace(), + monkeypatch.setattr(pipeline, "_wrapper_process_stream_response", lambda **kwargs: iter(())) + assert list(pipeline.process()) == [] + assert calls == [{"conversation_id": "conversation-1", "query": "hello", "message_id": "message-1"}] + + +@pytest.mark.parametrize("form_exists", [True, False]) +def test_human_input_association_is_persisted_once(message_case, form_exists): + pipeline = message_case.pipeline + node = "node-1" if form_exists else "missing" + pipeline._persist_human_input_extra_content(node_id=node) + pipeline._persist_human_input_extra_content(node_id=node) + with message_case.sessions() as session: + rows = list(session.scalars(select(HumanInputContent))) + assert [(row.message_id, row.form_id) for row in rows] == ([("message-1", "form-1")] if form_exists else []) + + +def test_pause_is_committed_before_client_observes_event(message_case): + pipeline = message_case.pipeline + list(pipeline._handle_workflow_started_event(QueueWorkflowStartedEvent())) + pipeline._task_state.answer = "before" + reason = HumanInputRequired( + form_id="form-1", + form_content="Approve?", + inputs=[], + actions=[], + node_id="node-1", + node_title="Approval", + resolved_default_values={}, ) - - stored_message = SimpleNamespace( - id="msg-1", - answer="", - status=MessageStatus.NORMAL, - updated_at=None, - provider_response_latency=0, - message_tokens=0, - message_unit_price=0, - message_price_unit=0, - answer_tokens=0, - answer_unit_price=0, - answer_price_unit=0, - total_price=0, - currency="USD", - message_metadata=None, - invoke_from=InvokeFrom.WEB_APP, - from_account_id=None, - from_end_user_id="user-1", + responses = pipeline._handle_workflow_paused_event( + QueueWorkflowPausedEvent(reasons=[reason], outputs={}, paused_nodes=["node-1"]) ) - pipeline._get_message = mock.Mock(return_value=stored_message) - pipeline._recorded_files = [] - - # Should not raise - pipeline._save_message(session=mock.Mock()) + next(responses) + assert message_case.message().status == MessageStatus.PAUSED + assert message_case.message().answer == "before" + with message_case.sessions() as session: + assert session.scalar(select(HumanInputContent.form_id)) == "form-1" + responses.close() + + +def test_resume_appends_chunks_and_resets_paused_status(message_case): + pipeline = message_case.pipeline + pipeline._task_state.answer = "before" + pipeline._save_message(paused=True) + pipeline._seed_task_state_from_message(MessageSnapshot.from_message(message_case.message())) + list(pipeline._handle_text_chunk_event(QueueTextChunkEvent(text="after"))) + pipeline._save_message() + assert message_case.message().answer == "beforeafter" + assert message_case.message().status == MessageStatus.NORMAL + + +def test_start_completion_files_and_error_use_initialization_database(message_case): + pipeline = message_case.pipeline + list(pipeline._handle_workflow_started_event(QueueWorkflowStartedEvent())) + assert message_case.message().workflow_run_id == "run-1" + pipeline._task_state.answer = "done" + pipeline._recorded_files = [ + {"type": "image", "transfer_method": "remote_url", "remote_url": "https://example.com/a.png"} + ] + responses = list(pipeline._handle_workflow_succeeded_event(QueueWorkflowSucceededEvent(outputs={}))) + assert responses[0].event == StreamEvent.MESSAGE_END + assert message_case.message().answer == "done" + with message_case.sessions() as session: + file = session.scalar(select(MessageFile)) + assert file.message_id == "message-1" + assert file.created_by == "user-1" + list(pipeline._handle_error_event(QueueErrorEvent(error=ValueError("failed")))) + assert message_case.message().status == MessageStatus.ERROR + assert message_case.message().error == "failed" + + +def test_failed_attachment_write_rolls_back_answer_and_status(message_case): + pipeline = message_case.pipeline + pipeline._task_state.answer = "uncommitted" + pipeline._recorded_files = [ + {"type": "image", "transfer_method": "remote_url", "remote_url": "https://example.com/a.png"} + ] + engine = message_case.sessions.kw["bind"] + + def fail_file(_conn, _cursor, sql, _params, _ctx, _many): + if sql.startswith("INSERT INTO message_files"): + raise RuntimeError("file write failed") + + event.listen(engine, "before_cursor_execute", fail_file) + try: + with pytest.raises(RuntimeError, match="file write failed"): + pipeline._save_message(paused=True) + finally: + event.remove(engine, "before_cursor_execute", fail_file) + assert message_case.message().answer == "" + assert message_case.message().status == MessageStatus.NORMAL + + +def test_deleted_message_does_not_suppress_start_or_error_event(message_case): + with message_case.sessions.begin() as session: + session.delete(session.get(Message, "message-1")) + pipeline = message_case.pipeline + assert list(pipeline._handle_workflow_started_event(QueueWorkflowStartedEvent())) + responses = list(pipeline._handle_error_event(QueueErrorEvent(error=ValueError("failed")))) + assert len(responses) == 1 + assert str(responses[0].err) == "failed" + + +@pytest.mark.parametrize("field", ["tenant_id", "app_id", "conversation_id"]) +def test_message_writes_check_owner_chain(message_case, field): + pipeline = message_case.pipeline + pipeline._message_identity = replace(pipeline._message_identity, **{field: "foreign"}) + with pytest.raises(MessageNotExistsError): + pipeline._save_message() + assert message_case.message().answer == "" + + +def test_trace_is_enqueued_after_message_commit(message_case): + pipeline = message_case.pipeline + traces = [] + + class TraceRecorder: + def add_trace_task(self, task): + assert message_case.message().answer == "done" + traces.append(task) + + pipeline._application_generate_entity.trace_manager = TraceRecorder() + pipeline._task_state.answer = "done" + pipeline._save_message() + assert traces == [] pipeline._emit_message_trace() + assert len(traces) == 1 + assert traces[0].message_id == "message-1" + assert traces[0].kwargs["trace_session_id"] == "trace-1" def test_workflow_succeeded_emits_message_end_before_workflow_finished() -> None: diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py index 34077fdebde939..170d8f90eaba9f 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_generate_task_pipeline_core.py @@ -1,18 +1,11 @@ from __future__ import annotations -from contextlib import nullcontext from types import SimpleNamespace import pytest from sqlalchemy.orm import Session from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig -from core.app.apps.advanced_chat.generate_task_pipeline import ( - AdvancedChatAppGenerateTaskPipeline, - ConversationSnapshot, - MessageSnapshot, - WorkflowSnapshot, -) from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom from core.app.entities.queue_entities import ( QueueAdvancedChatMessageEndEvent, @@ -60,11 +53,14 @@ from libs.datetime_utils import naive_utc_now from models.enums import MessageStatus from models.human_input_entities import UserActionConfig -from models.model import AppMode, EndUser, Message, MessageFile +from models.model import App, AppMode, Conversation, EndUser, Message, MessageFile +from services.app.generation.ports import ConversationSnapshot, MessageSnapshot, WorkflowSnapshot +from services.workflow.execution.adapters.chatflow.generate_task_pipeline import AdvancedChatAppGenerateTaskPipeline +from tests.unit_tests.model_factories import make_app, make_conversation from tests.workflow_test_utils import build_test_variable_pool -def _make_pipeline(): +def _make_pipeline(app_records, *, workflow_contexts, tool_providers): app_config = WorkflowUIBasedAppConfig( tenant_id="tenant", app_id="app", @@ -99,6 +95,8 @@ def _make_pipeline(): user = EndUser(tenant_id="tenant", type="session", name="tester", session_id="session") pipeline = AdvancedChatAppGenerateTaskPipeline( + contexts=workflow_contexts, + chat_records=app_records, application_generate_entity=application_generate_entity, workflow=workflow, queue_manager=SimpleNamespace(invoke_from=InvokeFrom.WEB_APP, graph_runtime_state=None), @@ -108,6 +106,7 @@ def _make_pipeline(): stream=False, dialogue_count=1, draft_var_saver_factory=lambda **kwargs: None, + tool_providers=tool_providers, ) return pipeline @@ -142,20 +141,24 @@ def _persist_message(session: Session, *, message_id: str = "message-id") -> Mes status=MessageStatus.PAUSED, ) message.id = message_id + if session.get(App, "app") is None: + session.add(make_app(app_id="app", tenant_id="tenant")) + if session.get(Conversation, "conv-id") is None: + session.add(make_conversation(conversation_id="conv-id", app_id="app", inputs={}, from_source="api")) session.add(message) session.commit() return message class TestAdvancedChatGenerateTaskPipeline: - def test_ensure_workflow_initialized_raises(self): - pipeline = _make_pipeline() + def test_ensure_workflow_initialized_raises(self, app_records, *, workflow_contexts, tool_providers): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) with pytest.raises(ValueError, match="workflow run not initialized"): pipeline._ensure_workflow_initialized() - def test_to_blocking_response_returns_message_end(self): - pipeline = _make_pipeline() + def test_to_blocking_response_returns_message_end(self, app_records, *, workflow_contexts, tool_providers): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) pipeline._task_state.answer = "done" def _gen(): @@ -166,8 +169,10 @@ def _gen(): assert response.data.answer == "done" assert response.data.metadata == {"k": "v"} - def test_to_blocking_response_falls_back_to_human_input_required_when_pause_event_missing(self): - pipeline = _make_pipeline() + def test_to_blocking_response_falls_back_to_human_input_required_when_pause_event_missing( + self, app_records, *, workflow_contexts, tool_providers + ): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) pipeline._task_state.answer = "partial answer" pipeline._workflow_run_id = "run-id" pipeline._graph_runtime_state = GraphRuntimeState( @@ -220,8 +225,8 @@ def _gen(): } ] - def test_handle_text_chunk_event_updates_state(self): - pipeline = _make_pipeline() + def test_handle_text_chunk_event_updates_state(self, app_records, *, workflow_contexts, tool_providers): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) pipeline._message_cycle_manager = SimpleNamespace( message_to_stream_response=lambda **kwargs: MessageEndStreamResponse( task_id="task", id="message-id", metadata={} @@ -235,8 +240,8 @@ def test_handle_text_chunk_event_updates_state(self): assert pipeline._task_state.answer == "hi" assert responses - def test_handle_reasoning_chunk_event_emits_on_nonempty(self): - pipeline = _make_pipeline() + def test_handle_reasoning_chunk_event_emits_on_nonempty(self, app_records, *, workflow_contexts, tool_providers): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) event = QueueReasoningChunkEvent(reasoning="pondering", from_node_id="llm-1", is_final=False) responses = list(pipeline._handle_reasoning_chunk_event(event)) @@ -251,16 +256,18 @@ def test_handle_reasoning_chunk_event_emits_on_nonempty(self): # reasoning never touches the answer stream assert pipeline._task_state.answer == "" - def test_handle_reasoning_chunk_event_drops_empty_nonfinal(self): - pipeline = _make_pipeline() + def test_handle_reasoning_chunk_event_drops_empty_nonfinal(self, app_records, *, workflow_contexts, tool_providers): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) event = QueueReasoningChunkEvent(reasoning="", from_node_id="llm-1", is_final=False) responses = list(pipeline._handle_reasoning_chunk_event(event)) assert responses == [] - def test_handle_reasoning_chunk_event_emits_empty_final_marker(self): - pipeline = _make_pipeline() + def test_handle_reasoning_chunk_event_emits_empty_final_marker( + self, app_records, *, workflow_contexts, tool_providers + ): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) event = QueueReasoningChunkEvent(reasoning="", from_node_id="llm-1", is_final=True) responses = list(pipeline._handle_reasoning_chunk_event(event)) @@ -271,33 +278,36 @@ def test_handle_reasoning_chunk_event_emits_empty_final_marker(self): assert response.data.reasoning == "" assert response.data.is_final is True - def test_listen_audio_msg_returns_audio_stream(self): - pipeline = _make_pipeline() + def test_listen_audio_msg_returns_audio_stream(self, app_records, *, workflow_contexts, tool_providers): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) publisher = SimpleNamespace(check_and_get_audio=lambda: AudioTrunk(status="responding", audio="data")) response = pipeline._listen_audio_msg(publisher=publisher, task_id="task") assert isinstance(response, MessageAudioStreamResponse) - def test_handle_ping_event(self): - pipeline = _make_pipeline() + def test_handle_ping_event(self, app_records, *, workflow_contexts, tool_providers): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) pipeline._base_task_pipeline.ping_stream_response = lambda: PingStreamResponse(task_id="task") responses = list(pipeline._handle_ping_event(QueuePingEvent())) assert isinstance(responses[0], PingStreamResponse) - def test_handle_error_event(self): - pipeline = _make_pipeline() + def test_handle_error_event(self, app_records, sqlite_session, *, workflow_contexts, tool_providers): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) pipeline._base_task_pipeline.handle_error = lambda **kwargs: ValueError("boom") + app_records.fail_message = lambda identity, error: None pipeline._base_task_pipeline.error_to_stream_response = lambda err: err responses = list(pipeline._handle_error_event(QueueErrorEvent(error=ValueError("boom")))) assert isinstance(responses[0], ValueError) - def test_handle_workflow_started_event_sets_run_id(self, sqlite_session: Session): - pipeline = _make_pipeline() + def test_handle_workflow_started_event_sets_run_id( + self, app_records, sqlite_session: Session, *, workflow_contexts, tool_providers + ): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) message = _persist_message(sqlite_session) other_message = _persist_message(sqlite_session, message_id="other-message-id") pipeline._graph_runtime_state = GraphRuntimeState( @@ -316,8 +326,10 @@ def test_handle_workflow_started_event_sets_run_id(self, sqlite_session: Session assert message.workflow_run_id == "run-id" assert other_message.workflow_run_id is None - def test_message_end_to_stream_response_strips_annotation_reply(self): - pipeline = _make_pipeline() + def test_message_end_to_stream_response_strips_annotation_reply( + self, app_records, *, workflow_contexts, tool_providers + ): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) pipeline._task_state.metadata.annotation_reply = AnnotationReply( id="ann", account=AnnotationReplyAccount(id="acc", name="acc"), @@ -327,8 +339,8 @@ def test_message_end_to_stream_response_strips_annotation_reply(self): assert "annotation_reply" not in response.metadata - def test_handle_output_moderation_chunk_publishes_stop(self): - pipeline = _make_pipeline() + def test_handle_output_moderation_chunk_publishes_stop(self, app_records, *, workflow_contexts, tool_providers): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) events: list[object] = [] class _Moderation: @@ -350,8 +362,8 @@ def get_final_output(self): assert any(isinstance(event, QueueTextChunkEvent) for event in events) assert any(isinstance(event, QueueStopEvent) for event in events) - def test_handle_node_succeeded_event_records_files(self): - pipeline = _make_pipeline() + def test_handle_node_succeeded_event_records_files(self, app_records, *, workflow_contexts, tool_providers): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) pipeline._workflow_response_converter.fetch_files_from_node_outputs = lambda outputs: [ {"type": "file", "transfer_method": "local"} ] @@ -370,8 +382,8 @@ def test_handle_node_succeeded_event_records_files(self): assert responses == ["done"] assert pipeline._recorded_files - def test_handle_node_succeeded_event_records_llm_reasoning(self): - pipeline = _make_pipeline() + def test_handle_node_succeeded_event_records_llm_reasoning(self, app_records, *, workflow_contexts, tool_providers): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) pipeline._workflow_response_converter.fetch_files_from_node_outputs = lambda outputs: [] pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "done" pipeline._save_output_for_event = lambda event, node_execution_id: None @@ -387,8 +399,10 @@ def test_handle_node_succeeded_event_records_llm_reasoning(self): assert pipeline._task_state.metadata.reasoning == {"llm-1": "first pass "} - def test_handle_node_succeeded_event_accumulates_reasoning_across_passes(self): - pipeline = _make_pipeline() + def test_handle_node_succeeded_event_accumulates_reasoning_across_passes( + self, app_records, *, workflow_contexts, tool_providers + ): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) pipeline._workflow_response_converter.fetch_files_from_node_outputs = lambda outputs: [] pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "done" pipeline._save_output_for_event = lambda event, node_execution_id: None @@ -407,8 +421,8 @@ def _llm_event(reasoning: str): assert pipeline._task_state.metadata.reasoning == {"llm-1": "pass one pass two"} - def test_iteration_and_loop_handlers(self): - pipeline = _make_pipeline() + def test_iteration_and_loop_handlers(self, app_records, *, workflow_contexts, tool_providers): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) pipeline._workflow_run_id = "run-id" pipeline._workflow_response_converter.workflow_iteration_start_to_stream_response = lambda **kwargs: ( "iter_start" @@ -477,8 +491,8 @@ def test_iteration_and_loop_handlers(self): assert list(pipeline._handle_loop_next_event(loop_next)) == ["loop_next"] assert list(pipeline._handle_loop_completed_event(loop_done)) == ["loop_done"] - def test_workflow_finish_handlers(self): - pipeline = _make_pipeline() + def test_workflow_finish_handlers(self, app_records, *, workflow_contexts, tool_providers): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) pipeline._workflow_run_id = "run-id" pipeline._graph_runtime_state = GraphRuntimeState( variable_pool=VariablePool.from_bootstrap( @@ -492,8 +506,8 @@ def test_workflow_finish_handlers(self): pipeline._save_message = lambda **kwargs: None pipeline._base_task_pipeline.queue_manager.publish = lambda *args, **kwargs: None pipeline._base_task_pipeline.handle_error = lambda **kwargs: ValueError("boom") + app_records.fail_message = lambda identity, error: None pipeline._base_task_pipeline.error_to_stream_response = lambda err: err - pipeline._get_message = lambda **kwargs: Message(id="message-id") succeeded_responses = list(pipeline._handle_workflow_succeeded_event(QueueWorkflowSucceededEvent(outputs={}))) assert len(succeeded_responses) == 2 @@ -516,8 +530,8 @@ def test_workflow_finish_handlers(self): "pause" ] - def test_node_failure_handlers(self): - pipeline = _make_pipeline() + def test_node_failure_handlers(self, app_records, *, workflow_contexts, tool_providers): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "node_finish" pipeline._save_output_for_event = lambda event, node_execution_id: None @@ -545,8 +559,8 @@ def test_node_failure_handlers(self): assert list(pipeline._handle_node_failed_events(failed_event)) == ["node_finish"] assert list(pipeline._handle_node_failed_events(exc_event)) == ["node_finish"] - def test_handle_text_chunk_event_tracks_streaming_metrics(self): - pipeline = _make_pipeline() + def test_handle_text_chunk_event_tracks_streaming_metrics(self, app_records, *, workflow_contexts, tool_providers): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) published: list[object] = [] class _Publisher: @@ -569,8 +583,8 @@ def publish(self, message): assert pipeline._task_state.answer == "hi" assert published == [queue_message] - def test_handle_output_moderation_chunk_appends_token(self): - pipeline = _make_pipeline() + def test_handle_output_moderation_chunk_appends_token(self, app_records, *, workflow_contexts, tool_providers): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) seen: list[str] = [] class _Moderation: @@ -587,14 +601,22 @@ def append_new_token(self, text): assert result is False assert seen == ["token"] - def test_handle_retriever_and_annotation_events(self, monkeypatch: pytest.MonkeyPatch, unbound_session: Session): - pipeline = _make_pipeline() + def test_handle_retriever_and_annotation_events( + self, + app_records, + monkeypatch: pytest.MonkeyPatch, + unbound_session: Session, + *, + workflow_contexts, + tool_providers, + ): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) calls = {"retriever": 0, "annotation": 0} def _hit_retriever(event): calls["retriever"] += 1 - def _hit_annotation(_manager, event, session): + def _hit_annotation(_manager, event): calls["annotation"] += 1 pipeline._message_cycle_manager.handle_retriever_resources = _hit_retriever @@ -603,14 +625,12 @@ def _hit_annotation(_manager, event, session): retriever_event = QueueRetrieverResourcesEvent(retriever_resources=[]) annotation_event = QueueAnnotationReplyEvent(message_annotation_id="ann") - monkeypatch.setattr(pipeline, "_database_session", lambda: nullcontext(unbound_session)) - assert list(pipeline._handle_retriever_resources_event(retriever_event)) == [] assert list(pipeline._handle_annotation_reply_event(annotation_event)) == [] assert calls == {"retriever": 1, "annotation": 1} - def test_handle_message_replace_event(self): - pipeline = _make_pipeline() + def test_handle_message_replace_event(self, app_records, *, workflow_contexts, tool_providers): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) pipeline._message_cycle_manager.message_replace_to_stream_response = lambda **kwargs: "replace" event = QueueMessageReplaceEvent( @@ -620,8 +640,8 @@ def test_handle_message_replace_event(self): assert list(pipeline._handle_message_replace_event(event)) == ["replace"] - def test_handle_human_input_events(self): - pipeline = _make_pipeline() + def test_handle_human_input_events(self, app_records, *, workflow_contexts, tool_providers): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) persisted: list[str] = [] pipeline._persist_human_input_extra_content = lambda **kwargs: persisted.append("saved") pipeline._workflow_response_converter.human_input_form_filled_to_stream_response = lambda **kwargs: "filled" @@ -647,8 +667,10 @@ def test_handle_human_input_events(self): assert list(pipeline._handle_human_input_form_timeout_event(timeout_event)) == ["timeout"] assert persisted == ["saved"] - def test_save_message_preserves_full_answer_and_sets_usage(self, sqlite_session: Session): - pipeline = _make_pipeline() + def test_save_message_preserves_full_answer_and_sets_usage( + self, app_records, sqlite_session: Session, *, workflow_contexts, tool_providers + ): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) pipeline._recorded_files = [ { "type": FileType.IMAGE, @@ -672,7 +694,8 @@ def test_save_message_preserves_full_answer_and_sets_usage(self, sqlite_session: start_at=0.0, ) - pipeline._save_message(session=sqlite_session, graph_runtime_state=graph_runtime_state) + pipeline._save_message(graph_runtime_state=graph_runtime_state) + sqlite_session.refresh(message) sqlite_session.commit() assert message.status == MessageStatus.NORMAL @@ -680,8 +703,8 @@ def test_save_message_preserves_full_answer_and_sets_usage(self, sqlite_session: assert message.message_metadata assert sqlite_session.query(MessageFile).filter_by(message_id=message.id).count() == 1 - def test_handle_stop_event_saves_message_for_moderation(self): - pipeline = _make_pipeline() + def test_handle_stop_event_saves_message_for_moderation(self, app_records, *, workflow_contexts, tool_providers): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) pipeline._message_end_to_stream_response = lambda: "end" saved: list[str] = [] @@ -695,8 +718,10 @@ def _save_message(**kwargs): assert responses == ["end"] assert saved == ["saved"] - def test_handle_message_end_event_applies_output_moderation(self): - pipeline = _make_pipeline() + def test_handle_message_end_event_applies_output_moderation( + self, app_records, *, workflow_contexts, tool_providers + ): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) pipeline._graph_runtime_state = GraphRuntimeState( variable_pool=VariablePool.from_bootstrap( system_variables=build_system_variables(workflow_execution_id="run-id") @@ -719,8 +744,8 @@ def _save_message(**kwargs): assert responses == ["replace", "end"] assert saved == ["saved"] - def test_dispatch_event_handles_node_exception(self): - pipeline = _make_pipeline() + def test_dispatch_event_handles_node_exception(self, app_records, *, workflow_contexts, tool_providers): + pipeline = _make_pipeline(app_records, workflow_contexts=workflow_contexts, tool_providers=tool_providers) pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "failed" pipeline._save_output_for_event = lambda *args, **kwargs: None diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py index 93d9a182aa3c51..7f86ea01e163e8 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py @@ -13,18 +13,13 @@ import inspect from decimal import Decimal from types import SimpleNamespace +from unittest.mock import Mock import pytest from pytest_mock import MockerFixture -from sqlalchemy import event from sqlalchemy.orm import Session -import core.app.apps.agent_app.app_generator as module -from core.app.apps.agent_app.app_generator import ( - AgentAppGenerator, - AgentAppGeneratorError, -) -from core.app.apps.agent_app.errors import AgentSessionSnapshotIncompatibleError +import services.app.generation.adapters.agent_app as module from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from core.app.entities.queue_entities import QueueAnnotationReplyEvent @@ -33,8 +28,15 @@ from models.agent_config_entities import AgentSoulConfig from models.enums import AppStatus, ConversationFromSource from models.model import App, AppMode, Conversation, Message, MessageAnnotation +from models.tool_runtime_contracts import WorkflowToolQueries +from services.app.generation.adapters.agent_app import ( + AgentAppGenerator, + AgentAppGeneratorError, +) +from services.app.generation.agent_config import AgentAppConfiguration, AgentAppConfigurations +from services.app.generation.errors import AgentSessionSnapshotIncompatibleError -MODULE = "core.app.apps.agent_app.app_generator" +MODULE = "services.app.generation.adapters.agent_app" def _account(user_id: str = "user") -> Account: @@ -122,7 +124,8 @@ def _message(*, query: str = "query") -> Message: def _bind_real_session(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch): global _CURRENT_SESSION _CURRENT_SESSION = sqlite_session - monkeypatch.setattr(module.db, "session", sqlite_session) + sqlite_session.add(_app()) + sqlite_session.commit() yield _CURRENT_SESSION = None @@ -133,10 +136,23 @@ def _session() -> Session: @pytest.fixture -def generator(mocker: MockerFixture) -> AgentAppGenerator: - gen = AgentAppGenerator() +def generator( + tool_providers, mocker: MockerFixture, *, annotation_replies, app_records, human_forms +) -> AgentAppGenerator: + gen = AgentAppGenerator( + agent_configs=Mock(spec=AgentAppConfigurations), + forms=human_forms, + annotations=annotation_replies, + records=app_records, + tool_providers=tool_providers, + workflow_queries=Mock(spec=WorkflowToolQueries), + ) mocker.patch(f"{MODULE}.current_app", new=mocker.MagicMock(_get_current_object=mocker.MagicMock())) mocker.patch(f"{MODULE}.contextvars.copy_context", return_value="ctx") + gen._agent_configs.resolve.return_value = AgentAppConfiguration( + "agent1", "snap1", "snapshot", AgentSoulConfig(), "home-1" + ) + gen._agent_configs.version.return_value = AgentAppConfiguration("a", "s", "snapshot", AgentSoulConfig(), "home-1") return gen @@ -148,7 +164,6 @@ def test_rejects_blocking_mode(self, generator: AgentAppGenerator): user=_account("u"), args={}, invoke_from=InvokeFrom.WEB_APP, - session=_session(), streaming=False, ) @@ -159,7 +174,6 @@ def test_requires_query(self, generator: AgentAppGenerator): user=_account("u"), args={"inputs": {}}, invoke_from=InvokeFrom.WEB_APP, - session=_session(), ) def test_rejects_blank_query(self, generator: AgentAppGenerator): @@ -169,25 +183,10 @@ def test_rejects_blank_query(self, generator: AgentAppGenerator): user=_account("u"), args={"query": " ", "inputs": {}}, invoke_from=InvokeFrom.WEB_APP, - session=_session(), ) class TestGenerateSuccess: - def test_session_scope_config_version_id_preserves_draft_or_snapshot_id(self): - assert ( - AgentAppGenerator._session_scope_config_version_id( - invoke_from=InvokeFrom.DEBUGGER, config_version_id="draft-1" - ) - == "draft-1" - ) - assert ( - AgentAppGenerator._session_scope_config_version_id( - invoke_from=InvokeFrom.WEB_APP, config_version_id="snapshot-1" - ) - == "snapshot-1" - ) - def test_generate_orchestrates_and_starts_worker(self, generator, mocker: MockerFixture): config = AppModelConfig(app_id="app1") config.id = "config-1" @@ -197,7 +196,6 @@ def test_generate_orchestrates_and_starts_worker(self, generator, mocker: Mocker app_model = _app(app_model_config_id=config.id) user = _account() - generator._resolve_agent = mocker.MagicMock(return_value=(_agent(), "snap1", "snapshot", AgentSoulConfig())) generator._prepare_user_inputs = mocker.MagicMock(return_value={"x": 1}) generator._init_generate_records = mocker.MagicMock(return_value=(_conversation(), _message())) generator._handle_response = mocker.MagicMock(return_value="raw-response") @@ -233,7 +231,6 @@ def test_generate_orchestrates_and_starts_worker(self, generator, mocker: Mocker user=user, args={"query": "hello", "inputs": {"name": "world"}, "files": file_mappings}, invoke_from=InvokeFrom.WEB_APP, - session=session, streaming=True, ) @@ -241,15 +238,16 @@ def test_generate_orchestrates_and_starts_worker(self, generator, mocker: Mocker thread_obj.start.assert_called_once() worker_call = thread_constructor.call_args inspect.signature(worker_call.kwargs["target"]).bind(**worker_call.kwargs["kwargs"]) - generator._resolve_agent.assert_called_once_with( - app_model, - invoke_from=InvokeFrom.WEB_APP, + generator._agent_configs.resolve.assert_called_once_with( + tenant_id=app_model.tenant_id, + app_id=app_model.id, + account_id=user.id, + debug=False, draft_type=None, - user=user, - session=session, - conversation=None, + conversation_id=None, ) - assert session.get(AppModelConfig, "config-1") is config + assert generate_entity.call_args.kwargs["agent_session_scope_config_version_id"] == "snap1" + assert session.get(AppModelConfig, "config-1").id == config.id build_files.assert_called_once() assert build_files.call_args.kwargs["mappings"] == file_mappings assert generate_entity.call_args.kwargs["files"] == [parsed_file] @@ -258,15 +256,12 @@ def test_generate_orchestrates_and_starts_worker(self, generator, mocker: Mocker def test_generate_loads_existing_conversation(self, generator: AgentAppGenerator, mocker: MockerFixture): app_model = _app() - generator._resolve_agent = mocker.MagicMock( - return_value=(_agent(agent_id="a"), "snap1", "snapshot", AgentSoulConfig()) - ) generator._prepare_user_inputs = mocker.MagicMock(return_value={}) generator._init_generate_records = mocker.MagicMock(return_value=(_conversation(), _message())) generator._handle_response = mocker.MagicMock(return_value="raw") - get_conv = mocker.patch(f"{MODULE}.ConversationService.get_conversation", return_value=_conversation()) + get_conv = mocker.patch.object(generator._records, "conversation", return_value=_conversation()) mocker.patch(f"{MODULE}.AgentAppConfigManager.get_app_config", return_value=mocker.MagicMock(variables=[])) - mocker.patch(f"{MODULE}.load_annotation_reply_config", return_value={"enabled": False}) + mocker.patch.object(generator._records, "annotation_config", return_value={"enabled": False}) mocker.patch(f"{MODULE}.ModelConfigConverter.convert", return_value=mocker.MagicMock()) mocker.patch(f"{MODULE}.TraceQueueManager", return_value=mocker.MagicMock()) mocker.patch(f"{MODULE}.AgentAppGenerateEntity", return_value=mocker.MagicMock()) @@ -281,18 +276,17 @@ def test_generate_loads_existing_conversation(self, generator: AgentAppGenerator user=user, args={"query": "hi", "inputs": {}, "conversation_id": "conv"}, invoke_from=InvokeFrom.WEB_APP, - session=session, streaming=True, ) get_conv.assert_called_once_with( - app_model=app_model, + app_id="app1", conversation_id="conv", - user=user, - session=session, + account_id="user", + end_user_id=None, ) - assert generator._resolve_agent.call_args.kwargs["conversation"].id == "conv" - assert generator._init_generate_records.call_args.kwargs["session"] is session + assert generator._agent_configs.resolve.call_args.kwargs["conversation_id"] == "conv" + assert "session" not in generator._init_generate_records.call_args.kwargs def test_generate_does_not_include_trace_session_id_in_extras( self, generator: AgentAppGenerator, mocker: MockerFixture @@ -300,7 +294,6 @@ def test_generate_does_not_include_trace_session_id_in_extras( app_model = _app() user = _account() - generator._resolve_agent = mocker.MagicMock(return_value=(_agent(), "snap1", "snapshot", AgentSoulConfig())) generator._prepare_user_inputs = mocker.MagicMock(return_value={}) generator._init_generate_records = mocker.MagicMock(return_value=(_conversation(), _message())) generator._handle_response = mocker.MagicMock(return_value="raw-response") @@ -323,7 +316,6 @@ def test_generate_does_not_include_trace_session_id_in_extras( user=user, args={"query": "hello", "inputs": {}, "trace_session_id": "session-1"}, invoke_from=InvokeFrom.WEB_APP, - session=_session(), streaming=True, ) @@ -348,18 +340,8 @@ def _wire( handled=False, guard_query="query", ): - generator._get_conversation = mocker.MagicMock(return_value=_conversation()) - generator._get_message = mocker.MagicMock(return_value=_message()) + mocker.patch.object(generator._records, "load", return_value=(_app(), _conversation(), _message())) generator._run_input_guards = mocker.MagicMock(return_value=(handled, guard_query, None)) - resolved_agent = _agent(agent_id="a") - resolved_config = _snapshot(snapshot_id="s", agent_id="a") - resolver_sessions: list[Session] = [] - - def resolve_agent_by_id(**kwargs): - resolver_sessions.append(kwargs["session"]) - return resolved_agent, resolved_config, AgentSoulConfig() - - generator._resolve_agent_by_id = mocker.MagicMock(side_effect=resolve_agent_by_id) session = _session() if session.get(App, "app1") is None: session.add(_app()) @@ -373,7 +355,7 @@ def resolve_agent_by_id(**kwargs): if run_side_effect is not None: runner.run.side_effect = run_side_effect mocker.patch(f"{MODULE}.AgentAppRunner", return_value=runner) - return runner, resolver_sessions + return runner, generator._agent_configs.version def _call( self, @@ -394,6 +376,7 @@ def _call( app_config=SimpleNamespace(app_id="app1", tenant_id="tenant"), agent_id="a", agent_config_snapshot_id="s", + agent_config_version_kind="snapshot", agent_session_scope_config_version_id=session_scope_config_version_id, model_conf=mocker.MagicMock(model="m"), query=query, @@ -412,8 +395,9 @@ def test_happy_path_runs_backend(self, generator: AgentAppGenerator, mocker: Moc queue_manager = mocker.MagicMock() self._call(generator, mocker, queue_manager) runner.run.assert_called_once() - assert resolver_sessions == [generator._resolve_agent_by_id.call_args.kwargs["session"]] - assert resolver_sessions[0].get_bind() is not None + resolver_sessions.assert_called_once_with( + tenant_id="tenant", app_id="app1", agent_id="a", version_id="s", version_kind="snapshot", account_id=None + ) assert runner.run.call_args.kwargs["home_snapshot_id"] == "home-1" assert "home_snapshot_ref" not in runner.run.call_args.kwargs queue_manager.publish_error.assert_not_called() @@ -450,7 +434,7 @@ def test_input_guard_short_circuit_skips_backend(self, generator, mocker: Mocker self._call(generator, mocker, queue_manager) runner.run.assert_not_called() - def test_annotation_reply_publishes_after_guard_transaction_commits(self, generator, mocker: MockerFixture): + def test_annotation_reply_publishes_after_guard_result(self, generator, mocker: MockerFixture): runner, _ = self._wire(generator, mocker, handled=True) annotation_reply = MessageAnnotation( app_id="app1", @@ -462,8 +446,11 @@ def test_annotation_reply_publishes_after_guard_transaction_commits(self, genera events: list[str] = [] queue_manager = mocker.MagicMock() - def record_commit(_session: Session) -> None: - events.append("commit") + def guard_result(**_kwargs): + events.append("queried") + return True, "query", annotation_reply + + generator._run_input_guards.side_effect = guard_result def publish(event, *_args): if isinstance(event, QueueAnnotationReplyEvent): @@ -471,13 +458,9 @@ def publish(event, *_args): queue_manager.publish.side_effect = publish - event.listen(type(_session()), "after_commit", record_commit) - try: - self._call(generator, mocker, queue_manager) - finally: - event.remove(type(_session()), "after_commit", record_commit) + self._call(generator, mocker, queue_manager) - assert events == ["commit", "publish"] + assert events == ["queried", "publish"] runner.run.assert_not_called() def test_resume_skips_input_guards_and_consumes_reply(self, generator, mocker: MockerFixture): @@ -529,20 +512,19 @@ class TestResumeAfterFormSubmission: composition's user-prompt layer matches the suspended snapshot (never blank).""" def _wire(self, generator, mocker: MockerFixture): - generator._resolve_agent = mocker.MagicMock(return_value=(_agent(), "snap1", "draft", AgentSoulConfig())) generator._init_generate_records = mocker.MagicMock(return_value=(_conversation(), _message())) generator._handle_response = mocker.MagicMock(return_value=None) - get_conversation = mocker.patch( - f"{MODULE}.ConversationService.get_conversation", + get_conversation = mocker.patch.object( + generator._records, + "conversation", return_value=_conversation(), ) mocker.patch(f"{MODULE}.AgentAppConfigManager.get_app_config", return_value=mocker.MagicMock(variables=[])) - mocker.patch(f"{MODULE}.load_annotation_reply_config", return_value={"enabled": False}) + mocker.patch.object(generator._records, "annotation_config", return_value={"enabled": False}) mocker.patch(f"{MODULE}.ModelConfigConverter.convert", return_value=mocker.MagicMock()) mocker.patch(f"{MODULE}.TraceQueueManager", return_value=mocker.MagicMock()) mocker.patch(f"{MODULE}.MessageBasedAppQueueManager", return_value=mocker.MagicMock()) mocker.patch(f"{MODULE}.threading.Thread", return_value=mocker.MagicMock()) - generator._resolve_resume_draft = mocker.MagicMock(return_value=(None, None)) return ( mocker.patch( f"{MODULE}.AgentAppGenerateEntity", return_value=mocker.MagicMock(task_id="t", user_id="user") @@ -566,21 +548,19 @@ def test_resume_resends_paused_turn_query(self, generator, mocker: MockerFixture conversation_id="conv", form_id="form-1", invoke_from=InvokeFrom.WEB_APP, - session=session, ) # The paused turn's query is re-sent verbatim — never blank. assert entity.call_args.kwargs["query"] == "original question" assert "agent_runtime_exit_intent" not in entity.call_args.kwargs get_conversation.assert_called_once_with( - app_model=app_model, + app_id="app1", conversation_id="conv", - user=user, - session=session, + account_id="user", + end_user_id=None, ) - assert generator._init_generate_records.call_args.kwargs["session"] is session - assert session.get(AppModelConfig, "config-1") is config - assert generator._resolve_agent.call_args.kwargs["session"] is session + assert "session" not in generator._init_generate_records.call_args.kwargs + assert session.get(AppModelConfig, "config-1").id == config.id def test_resume_falls_back_to_placeholder_when_no_paused_message(self, generator, mocker: MockerFixture): entity, _ = self._wire(generator, mocker) @@ -592,7 +572,6 @@ def test_resume_falls_back_to_placeholder_when_no_paused_message(self, generator conversation_id="conv", form_id="form-1", invoke_from=InvokeFrom.WEB_APP, - session=session, ) # No prior user message -> a non-blank placeholder, still never blank. @@ -601,8 +580,10 @@ def test_resume_falls_back_to_placeholder_when_no_paused_message(self, generator def test_resume_uses_build_draft_for_debugger_conversation(self, generator, mocker: MockerFixture): self._wire(generator, mocker) conversation = _conversation(invoke_from=InvokeFrom.DEBUGGER) - mocker.patch(f"{MODULE}.ConversationService.get_conversation", return_value=conversation) - generator._resolve_resume_draft.return_value = ("debug_build", "draft-build-1") + mocker.patch.object(generator._records, "conversation", return_value=conversation) + generator._agent_configs.resolve.return_value = AgentAppConfiguration( + "agent1", "draft-build-1", "build_draft", AgentSoulConfig(), "home-1" + ) account_user = Account(name="Test Account", email="test@example.com") account_user.id = "user" session = _session() @@ -618,10 +599,14 @@ def test_resume_uses_build_draft_for_debugger_conversation(self, generator, mock conversation_id="conv", form_id="form-1", invoke_from=InvokeFrom.DEBUGGER, - session=session, ) - assert generator._resolve_agent.call_args.kwargs["draft_type"] == "debug_build" - assert generator._resolve_agent.call_args.kwargs["draft_id"] == "draft-build-1" - assert generator._resolve_agent.call_args.kwargs["session"] is session - assert generator._resolve_agent.call_args.kwargs["conversation"] is conversation + generator._agent_configs.resolve.assert_called_once_with( + tenant_id=app_model.tenant_id, + app_id=app_model.id, + account_id="user", + debug=True, + draft_type=None, + conversation_id=conversation.id, + form_id="form-1", + ) diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py index 1099a252dea7e6..b77e08ffddcd7f 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_runner.py @@ -51,10 +51,6 @@ FakeAgentBackendRunClient, FakeAgentBackendScenario, ) -from core.app.apps.agent_app import app_runner as app_runner_module -from core.app.apps.agent_app.app_runner import AgentAppRunner -from core.app.apps.agent_app.errors import AgentSessionSnapshotIncompatibleError -from core.app.apps.agent_app.runtime_request_builder import AgentAppRuntimeBuildContext, AgentAppRuntimeRequestBuilder from core.app.apps.agent_app.session_store import AgentAppSessionScope, StoredAgentAppSession from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import DifyRunContext, InvokeFrom, UserFrom @@ -65,20 +61,38 @@ QueueMessageEndEvent, ) from core.workflow.nodes.agent_v2.ask_human_resume import AskHumanResumeOutcome -from core.workflow.nodes.agent_v2.dify_tools_builder import WorkflowAgentToolLayers from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage from graphon.model_runtime.errors.invoke import InvokeRateLimitError from models.agent_config_entities import AgentSoulConfig from models.enums import ConversationFromSource from models.model import AppMode, Message, MessageAgentThought +from services.app.generation.adapters import agent_runner as app_runner_module +from services.app.generation.adapters.agent_request_builder import ( + AgentAppRuntimeBuildContext, + AgentAppRuntimeRequestBuilder, +) +from services.app.generation.adapters.agent_runner import AgentAppRunner +from services.app.generation.errors import AgentSessionSnapshotIncompatibleError +from services.app.generation.ports import MessageIdentity +from services.workflow.execution.adapters.agent_v2.dify_tools_builder import WorkflowAgentToolLayers +from tests.unit_tests.model_factories import make_app, make_conversation @pytest.fixture(autouse=True) def bind_agent_dependencies(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session) -> None: """Bind local runner dependencies without reaching external services.""" - monkeypatch.setattr(app_runner_module.db, "session", sqlite_session) + sqlite_session.add_all( + [ + make_app(), + make_conversation( + conversation_id="conv-1", app_id="app-1", inputs={}, from_source=ConversationFromSource.API + ), + _message_record(), + ] + ) + sqlite_session.commit() monkeypatch.setattr( - "core.app.apps.agent_app.runtime_request_builder.resolve_model_context_window", + "services.app.generation.adapters.agent_request_builder.resolve_model_context_window", lambda **_kwargs: None, ) @@ -663,8 +677,12 @@ def _runner( store: _FakeSessionStore, *, text_delta_debounce_seconds: float = 0, + app_records, + human_forms, ) -> AgentAppRunner: return AgentAppRunner( + forms=human_forms, + records=app_records, request_builder=AgentAppRuntimeRequestBuilder( dify_tools_builder=_NoToolsBuilder(), # type: ignore[arg-type] ), @@ -736,12 +754,12 @@ def _saved_user_query(qm: _FakeQueueManager) -> str: return content -def test_successful_turn_publishes_chunk_and_message_end_and_saves_session() -> None: +def test_successful_turn_publishes_chunk_and_message_end_and_saves_session(*, app_records, human_forms) -> None: client = FakeAgentBackendRunClient() # SUCCESS: output {"text": "hello agent"} store = _FakeSessionStore() qm = _FakeQueueManager() - _run(_runner(client, store), qm) + _run(_runner(client, store, human_forms=human_forms, app_records=app_records), qm) assert client.request is not None assert client.request.on_exit.default.value == "suspend" @@ -766,11 +784,11 @@ def test_successful_turn_publishes_chunk_and_message_end_and_saves_session() -> assert pending_tool_call_id is None -def test_turn_uses_resolved_backend_binding_before_backend_invocation() -> None: +def test_turn_uses_resolved_backend_binding_before_backend_invocation(*, app_records, human_forms) -> None: client = FakeAgentBackendRunClient() store = _FakeSessionStore(binding_id="binding-2", backend_binding_ref="backend-binding-2") - _run(_runner(client, store), _FakeQueueManager()) + _run(_runner(client, store, human_forms=human_forms, app_records=app_records), _FakeQueueManager()) assert client.request is not None layers = {layer["name"]: layer for layer in client.request.model_dump(mode="json")["composition"]["layers"]} @@ -780,13 +798,13 @@ def test_turn_uses_resolved_backend_binding_before_backend_invocation() -> None: def test_successful_turn_routes_stream_text_to_agent_message_and_uses_terminal_output( - sqlite_session: Session, + sqlite_session: Session, *, app_records, human_forms ) -> None: client = _StreamingFakeAgentBackendRunClient() store = _FakeSessionStore() qm = _FakeQueueManager() - _run(_runner(client, store), qm) + _run(_runner(client, store, human_forms=human_forms, app_records=app_records), qm) chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)] @@ -808,12 +826,16 @@ def test_successful_turn_routes_stream_text_to_agent_message_and_uses_terminal_o assert store.saved -def test_successful_turn_persists_usage_without_a_queue_consumer(sqlite_session: Session) -> None: - sqlite_session.add(_message_record()) - sqlite_session.flush() +def test_successful_turn_persists_usage_without_a_queue_consumer( + sqlite_session: Session, *, app_records, human_forms +) -> None: + sqlite_session.merge(_message_record()) + sqlite_session.commit() _run( - _runner(_StreamingFakeAgentBackendRunClient(), _FakeSessionStore()), + _runner( + _StreamingFakeAgentBackendRunClient(), _FakeSessionStore(), human_forms=human_forms, app_records=app_records + ), _FakeQueueManager(), ) @@ -827,12 +849,14 @@ def test_successful_turn_persists_usage_without_a_queue_consumer(sqlite_session: assert json.loads(message.message_metadata)["usage"]["total_tokens"] == 8 -def test_successful_turn_routes_single_agent_message_delta(sqlite_session: Session) -> None: +def test_successful_turn_routes_single_agent_message_delta( + sqlite_session: Session, *, app_records, human_forms +) -> None: client = _StreamingSingleAgentMessageDeltaFakeAgentBackendRunClient() store = _FakeSessionStore() qm = _FakeQueueManager() - _run(_runner(client, store), qm) + _run(_runner(client, store, human_forms=human_forms, app_records=app_records), qm) chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)] @@ -845,7 +869,9 @@ def test_successful_turn_routes_single_agent_message_delta(sqlite_session: Sessi assert rows == [] -def test_thought_commit_failure_rolls_back_and_turn_continues(sqlite_session: Session) -> None: +def test_thought_commit_failure_rolls_back_and_turn_continues( + sqlite_session: Session, *, app_records, human_forms +) -> None: rollback_events: list[Session] = [] should_fail = True @@ -858,29 +884,32 @@ def fail_first_commit(_session: Session) -> None: def record_rollback(session: Session) -> None: rollback_events.append(session) - event.listen(sqlite_session, "before_commit", fail_first_commit) - event.listen(sqlite_session, "after_rollback", record_rollback) + event.listen(Session, "before_commit", fail_first_commit) + event.listen(Session, "after_rollback", record_rollback) try: client = _StreamingSingleAgentMessageDeltaFakeAgentBackendRunClient() store = _FakeSessionStore() qm = _FakeQueueManager() - _run(_runner(client, store), qm) + _run(_runner(client, store, human_forms=human_forms, app_records=app_records), qm) finally: - event.remove(sqlite_session, "before_commit", fail_first_commit) - event.remove(sqlite_session, "after_rollback", record_rollback) + event.remove(Session, "before_commit", fail_first_commit) + event.remove(Session, "after_rollback", record_rollback) - assert rollback_events == [sqlite_session] + assert len(rollback_events) == 1 + assert rollback_events[0] is not sqlite_session assert _thought_rows(sqlite_session) == [] assert _llm_result(qm).message.content == "hello agent" -def test_successful_turn_with_null_terminal_output_publishes_empty_answer_not_literal_null() -> None: +def test_successful_turn_with_null_terminal_output_publishes_empty_answer_not_literal_null( + *, app_records, human_forms +) -> None: client = _NullOutputFakeAgentBackendRunClient() store = _FakeSessionStore() qm = _FakeQueueManager() - _run(_runner(client, store), qm) + _run(_runner(client, store, human_forms=human_forms, app_records=app_records), qm) chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)] @@ -892,13 +921,13 @@ def test_successful_turn_with_null_terminal_output_publishes_empty_answer_not_li def test_successful_turn_with_stream_text_and_null_terminal_output_keeps_empty_message( - sqlite_session: Session, + sqlite_session: Session, *, app_records, human_forms ) -> None: client = _StreamingTextNullOutputFakeAgentBackendRunClient() store = _FakeSessionStore() qm = _FakeQueueManager() - _run(_runner(client, store), qm) + _run(_runner(client, store, human_forms=human_forms, app_records=app_records), qm) chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)] @@ -912,12 +941,14 @@ def test_successful_turn_with_stream_text_and_null_terminal_output_keeps_empty_m assert rows[0].answer == "streamed answer" -def test_successful_turn_routes_agent_answer_to_agent_message(sqlite_session: Session) -> None: +def test_successful_turn_routes_agent_answer_to_agent_message( + sqlite_session: Session, *, app_records, human_forms +) -> None: client = _AgentAnswerStreamingFakeAgentBackendRunClient() store = _FakeSessionStore() qm = _FakeQueueManager() - _run(_runner(client, store), qm) + _run(_runner(client, store, human_forms=human_forms, app_records=app_records), qm) chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)] @@ -937,14 +968,14 @@ def test_successful_turn_routes_agent_answer_to_agent_message(sqlite_session: Se def test_agent_message_deltas_are_debounced_to_agent_message( - monkeypatch: pytest.MonkeyPatch, sqlite_session: Session + monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, *, app_records, human_forms ) -> None: monkeypatch.setattr(app_runner_module.time, "monotonic", _MonotonicClock(0.0, 0.2)) client = _StreamingFakeAgentBackendRunClient() store = _FakeSessionStore() qm = _FakeQueueManager() - _run(_runner(client, store, text_delta_debounce_seconds=0.5), qm) + _run(_runner(client, store, human_forms=human_forms, app_records=app_records, text_delta_debounce_seconds=0.5), qm) chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)] @@ -955,13 +986,13 @@ def test_agent_message_deltas_are_debounced_to_agent_message( def test_successful_turn_persists_thinking_and_tool_process_events( - sqlite_session: Session, + sqlite_session: Session, *, app_records, human_forms ) -> None: client = _ProcessStreamingFakeAgentBackendRunClient() store = _FakeSessionStore() qm = _FakeQueueManager() - _run(_runner(client, store), qm) + _run(_runner(client, store, human_forms=human_forms, app_records=app_records), qm) chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)] @@ -980,14 +1011,14 @@ def test_successful_turn_persists_thinking_and_tool_process_events( def test_streaming_turn_cancels_after_persisting_seen_agent_answer( - sqlite_session: Session, + sqlite_session: Session, *, app_records, human_forms ) -> None: store = _FakeSessionStore() qm = _FakeQueueManager() client = _StreamingStopAfterFirstDeltaFakeAgentBackendRunClient(queue_manager=qm) with pytest.raises(GenerateTaskStoppedError): - _run(_runner(client, store), qm) + _run(_runner(client, store, human_forms=human_forms, app_records=app_records), qm) chunk_events = [e for e in qm.events if isinstance(e, QueueLLMChunkEvent)] agent_message_events = [e for e in qm.events if isinstance(e, QueueAgentMessageEvent)] @@ -1000,11 +1031,11 @@ def test_streaming_turn_cancels_after_persisting_seen_agent_answer( assert client.cancel_after == ["3-0"] -def test_tool_result_without_identity_does_not_attach_to_previous_tool( - sqlite_session: Session, -) -> None: +def test_tool_result_without_identity_does_not_attach_to_previous_tool(sqlite_session: Session, *, app_records) -> None: qm = _FakeQueueManager() recorder = app_runner_module._AgentProcessRecorder( + records=app_records, + conversation_id="conv-1", dify_context=_dify_ctx(), message_id="msg-1", queue_manager=qm, # type: ignore[arg-type] @@ -1044,9 +1075,11 @@ def test_tool_result_without_identity_does_not_attach_to_previous_tool( assert rows[1].observation == "Knowledge base search results: browser skill" -def test_answer_suffix_trim_keeps_non_terminal_prefix(sqlite_session: Session) -> None: +def test_answer_suffix_trim_keeps_non_terminal_prefix(sqlite_session: Session, *, app_records) -> None: qm = _FakeQueueManager() recorder = app_runner_module._AgentProcessRecorder( + records=app_records, + conversation_id="conv-1", dify_context=_dify_ctx(), message_id="msg-1", queue_manager=qm, # type: ignore[arg-type] @@ -1060,9 +1093,11 @@ def test_answer_suffix_trim_keeps_non_terminal_prefix(sqlite_session: Session) - assert rows[0].answer == "intermediate " -def test_tool_call_part_binds_late_call_id_to_delta_row(sqlite_session: Session) -> None: +def test_tool_call_part_binds_late_call_id_to_delta_row(sqlite_session: Session, *, app_records) -> None: qm = _FakeQueueManager() recorder = app_runner_module._AgentProcessRecorder( + records=app_records, + conversation_id="conv-1", dify_context=_dify_ctx(), message_id="msg-1", queue_manager=qm, # type: ignore[arg-type] @@ -1119,9 +1154,11 @@ def test_tool_call_part_binds_late_call_id_to_delta_row(sqlite_session: Session) assert rows[0].observation == "Knowledge base search results: browser skill" -def test_thinking_after_tool_starts_new_snapshot_row(sqlite_session: Session) -> None: +def test_thinking_after_tool_starts_new_snapshot_row(sqlite_session: Session, *, app_records) -> None: qm = _FakeQueueManager() recorder = app_runner_module._AgentProcessRecorder( + records=app_records, + conversation_id="conv-1", dify_context=_dify_ctx(), message_id="msg-1", queue_manager=qm, # type: ignore[arg-type] @@ -1175,11 +1212,11 @@ def test_thinking_after_tool_starts_new_snapshot_row(sqlite_session: Session) -> assert rows[1].tool_input == '{"cmd": "date"}' -def test_tool_result_without_call_id_matches_unique_open_tool_name( - sqlite_session: Session, -) -> None: +def test_tool_result_without_call_id_matches_unique_open_tool_name(sqlite_session: Session, *, app_records) -> None: qm = _FakeQueueManager() recorder = app_runner_module._AgentProcessRecorder( + records=app_records, + conversation_id="conv-1", dify_context=_dify_ctx(), message_id="msg-1", queue_manager=qm, # type: ignore[arg-type] @@ -1220,10 +1257,12 @@ def test_tool_result_without_call_id_matches_unique_open_tool_name( def test_repeated_tool_calls_without_call_id_or_index_create_distinct_rows( - sqlite_session: Session, + sqlite_session: Session, *, app_records ) -> None: qm = _FakeQueueManager() recorder = app_runner_module._AgentProcessRecorder( + records=app_records, + conversation_id="conv-1", dify_context=_dify_ctx(), message_id="msg-1", queue_manager=qm, # type: ignore[arg-type] @@ -1293,10 +1332,12 @@ def test_repeated_tool_calls_without_call_id_or_index_create_distinct_rows( def test_repeated_tool_calls_with_placeholder_call_id_and_reused_index_create_distinct_rows( - sqlite_session: Session, + sqlite_session: Session, *, app_records ) -> None: qm = _FakeQueueManager() recorder = app_runner_module._AgentProcessRecorder( + records=app_records, + conversation_id="conv-1", dify_context=_dify_ctx(), message_id="msg-1", queue_manager=qm, # type: ignore[arg-type] @@ -1343,19 +1384,19 @@ def test_repeated_tool_calls_with_placeholder_call_id_and_reused_index_create_di assert rows[1].observation == "out output" -def test_prior_session_snapshot_is_threaded_into_request() -> None: +def test_prior_session_snapshot_is_threaded_into_request(*, app_records, human_forms) -> None: prior = _compatible_session_snapshot() client = FakeAgentBackendRunClient() store = _FakeSessionStore(loaded=prior) qm = _FakeQueueManager() - _run(_runner(client, store), qm) + _run(_runner(client, store, human_forms=human_forms, app_records=app_records), qm) assert client.request is not None assert client.request.session_snapshot is prior -def test_incompatible_session_snapshot_is_rejected_before_backend_invocation() -> None: +def test_incompatible_session_snapshot_is_rejected_before_backend_invocation(*, app_records, human_forms) -> None: compatible = _compatible_session_snapshot() stale = CompositorSessionSnapshot( layers=[layer for layer in compatible.layers if layer.name != "agent_soul_prompt"] @@ -1364,19 +1405,19 @@ def test_incompatible_session_snapshot_is_rejected_before_backend_invocation() - store = _FakeSessionStore(loaded=stale) with pytest.raises(AgentSessionSnapshotIncompatibleError, match="Start a new conversation"): - _run(_runner(client, store), _FakeQueueManager()) + _run(_runner(client, store, human_forms=human_forms, app_records=app_records), _FakeQueueManager()) assert client.request is None assert store.saved == [] -def test_debug_session_scope_can_reuse_conversation_across_config_snapshots() -> None: +def test_debug_session_scope_can_reuse_conversation_across_config_snapshots(*, app_records, human_forms) -> None: prior = _compatible_session_snapshot() client = FakeAgentBackendRunClient() store = _FakeSessionStore(loaded=prior) qm = _FakeQueueManager() - _runner(client, store).run( + _runner(client, store, human_forms=human_forms, app_records=app_records).run( dify_context=_dify_ctx(), agent_id="agent-1", agent_config_snapshot_id="snap-new", @@ -1396,24 +1437,27 @@ def test_debug_session_scope_can_reuse_conversation_across_config_snapshots() -> assert store.saved[0][0].agent_config_snapshot_id == "snap-new" -def test_failed_run_raises_agent_backend_error() -> None: +def test_failed_run_raises_agent_backend_error(*, app_records, human_forms) -> None: client = FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario.FAILED) store = _FakeSessionStore() qm = _FakeQueueManager() with pytest.raises(AgentBackendRunFailedError, match="fake failure .*agent_run_id=fake-run-1"): - _run(_runner(client, store), qm) + _run(_runner(client, store, human_forms=human_forms, app_records=app_records), qm) # No message-end on failure; post-exit session state is still saved. assert not [e for e in qm.events if isinstance(e, QueueMessageEndEvent)] assert store.saved[0][2] == CompositorSessionSnapshot(layers=[]) -def test_failed_run_persists_partial_usage(sqlite_session: Session) -> None: - sqlite_session.add(_message_record()) - sqlite_session.flush() +def test_failed_run_persists_partial_usage(sqlite_session: Session, *, app_records, human_forms) -> None: + sqlite_session.merge(_message_record()) + sqlite_session.commit() with pytest.raises(AgentBackendRunFailedError, match="failed after model calls"): - _run(_runner(_UsageFailedClient(), _FakeSessionStore()), _FakeQueueManager()) + _run( + _runner(_UsageFailedClient(), _FakeSessionStore(), human_forms=human_forms, app_records=app_records), + _FakeQueueManager(), + ) sqlite_session.expire_all() message = sqlite_session.get(Message, "msg-1") @@ -1426,22 +1470,28 @@ def test_failed_run_persists_partial_usage(sqlite_session: Session) -> None: assert json.loads(message.message_metadata)["usage"]["total_tokens"] == 12 -def test_partial_usage_persistence_ignores_missing_message() -> None: - AgentAppRunner._persist_message_usage( - message_id="missing", +def test_partial_usage_persistence_ignores_missing_message(app_records, *, human_forms) -> None: + _runner( + FakeAgentBackendRunClient(), _FakeSessionStore(), human_forms=human_forms, app_records=app_records + )._persist_message_usage( + identity=MessageIdentity("tenant-1", "app-1", "conv-1", "missing"), usage=LLMUsage.from_metadata({"prompt_tokens": 2, "completion_tokens": 1}), ) @pytest.mark.parametrize("metadata", ["{", "[]"]) -def test_partial_usage_persistence_recovers_invalid_metadata(metadata: str, sqlite_session: Session) -> None: +def test_partial_usage_persistence_recovers_invalid_metadata( + metadata: str, sqlite_session: Session, app_records, *, human_forms +) -> None: message = _message_record() message.message_metadata = metadata - sqlite_session.add(message) - sqlite_session.flush() + sqlite_session.merge(message) + sqlite_session.commit() - AgentAppRunner._persist_message_usage( - message_id=message.id, + _runner( + FakeAgentBackendRunClient(), _FakeSessionStore(), human_forms=human_forms, app_records=app_records + )._persist_message_usage( + identity=MessageIdentity("tenant-1", "app-1", "conv-1", message.id), usage=LLMUsage.from_metadata({"prompt_tokens": 2, "completion_tokens": 1}), ) @@ -1452,21 +1502,31 @@ def test_partial_usage_persistence_recovers_invalid_metadata(metadata: str, sqli assert json.loads(persisted.message_metadata)["usage"]["total_tokens"] == 3 -def test_partial_usage_persistence_rolls_back_database_error(monkeypatch: pytest.MonkeyPatch) -> None: - session = MagicMock() - session.get.side_effect = RuntimeError("database unavailable") - monkeypatch.setattr(app_runner_module.db, "session", session) +def test_partial_usage_persistence_rolls_back_database_error( + app_records, sqlite_session: Session, *, human_forms +) -> None: + engine = sqlite_session.get_bind() - AgentAppRunner._persist_message_usage( - message_id="msg-1", - usage=LLMUsage.from_metadata({"prompt_tokens": 2, "completion_tokens": 1}), - ) + def fail(_conn, _cursor, statement, _params, _ctx, _many): + if statement.startswith("UPDATE messages"): + raise RuntimeError("database unavailable") - session.rollback.assert_called_once() + event.listen(engine, "before_cursor_execute", fail) + try: + _runner( + FakeAgentBackendRunClient(), _FakeSessionStore(), human_forms=human_forms, app_records=app_records + )._persist_message_usage( + identity=MessageIdentity("tenant-1", "app-1", "conv-1", "msg-1"), + usage=LLMUsage.from_metadata({"prompt_tokens": 2, "completion_tokens": 1}), + ) + finally: + event.remove(engine, "before_cursor_execute", fail) + sqlite_session.expire_all() + assert sqlite_session.get(Message, "msg-1").message_tokens == 0 @pytest.mark.parametrize("outcome", ["failed", "stopped"]) -def test_snapshot_save_failure_preserves_original_app_outcome(outcome: str) -> None: +def test_snapshot_save_failure_preserves_original_app_outcome(outcome: str, *, app_records, human_forms) -> None: store = _ExplodingSessionStore() queue_manager: _FakeQueueManager = _FakeQueueManager() if outcome == "failed" else _StoppedQueueManager() client = FakeAgentBackendRunClient( @@ -1475,7 +1535,7 @@ def test_snapshot_save_failure_preserves_original_app_outcome(outcome: str) -> N expected_error = AgentBackendRunFailedError if outcome == "failed" else GenerateTaskStoppedError with pytest.raises(expected_error, match="fake failure" if outcome == "failed" else None): - _run(_runner(client, store), queue_manager) + _run(_runner(client, store, human_forms=human_forms, app_records=app_records), queue_manager) assert store.save_attempts == [CompositorSessionSnapshot(layers=[])] @@ -1485,24 +1545,23 @@ def test_snapshot_save_failure_preserves_original_app_outcome(outcome: str) -> N [("failed", AgentBackendRunFailedError), ("cancelled", AgentBackendError)], ) def test_terminal_without_snapshot_preserves_prior_app_session_without_write( - terminal_type: str, - expected_error: type[Exception], + terminal_type: str, expected_error: type[Exception], *, app_records, human_forms ) -> None: store = _FakeSessionStore() client = _TerminalWithoutSnapshotFakeAgentBackendRunClient(terminal_type=terminal_type) with pytest.raises(expected_error): - _run(_runner(client, store), _FakeQueueManager()) + _run(_runner(client, store, human_forms=human_forms, app_records=app_records), _FakeQueueManager()) assert store.saved == [] -def test_failed_run_prefers_run_failure_type_over_binding_lost_reason() -> None: +def test_failed_run_prefers_run_failure_type_over_binding_lost_reason(*, app_records, human_forms) -> None: client = _RunLimitBindingLostFakeAgentBackendRunClient() store = _FakeSessionStore() with pytest.raises(AgentBackendRunFailedError) as raised: - _run(_runner(client, store), _FakeQueueManager()) + _run(_runner(client, store, human_forms=human_forms, app_records=app_records), _FakeQueueManager()) assert raised.value.error_type is RunFailureType.AGENT_RUN_LIMIT_EXCEEDED assert raised.value.reason == "binding_lost" @@ -1563,26 +1622,29 @@ def test_agent_backend_failure_to_exception_prefers_run_failure_type_over_known_ } -def test_stopped_task_waits_for_cancelled_snapshot_and_saves_session() -> None: +def test_stopped_task_waits_for_cancelled_snapshot_and_saves_session(*, app_records, human_forms) -> None: client = _RecordingFakeAgentBackendRunClient() store = _FakeSessionStore() qm = _StoppedQueueManager() with pytest.raises(GenerateTaskStoppedError): - _run(_runner(client, store), qm) + _run(_runner(client, store, human_forms=human_forms, app_records=app_records), qm) assert client.cancelled_run_ids == ["fake-run-1"] assert len(store.saved) == 1 assert store.saved[0][2] == CompositorSessionSnapshot(layers=[]) -def test_stopped_task_persists_partial_usage(sqlite_session: Session) -> None: - sqlite_session.add(_message_record()) - sqlite_session.flush() +def test_stopped_task_persists_partial_usage(sqlite_session: Session, *, app_records, human_forms) -> None: + sqlite_session.merge(_message_record()) + sqlite_session.commit() client = _UsageCancellationClient() with pytest.raises(GenerateTaskStoppedError): - _run(_runner(client, _FakeSessionStore()), _StoppedQueueManager()) + _run( + _runner(client, _FakeSessionStore(), human_forms=human_forms, app_records=app_records), + _StoppedQueueManager(), + ) sqlite_session.expire_all() message = sqlite_session.get(Message, "msg-1") @@ -1594,12 +1656,12 @@ def test_stopped_task_persists_partial_usage(sqlite_session: Session) -> None: assert json.loads(message.message_metadata)["usage"]["total_tokens"] == 21 -def test_cancel_and_wait_failure_preserves_stopped_app_outcome() -> None: +def test_cancel_and_wait_failure_preserves_stopped_app_outcome(*, app_records, human_forms) -> None: client = _CancelAndWaitFailingClient() store = _FakeSessionStore() with pytest.raises(GenerateTaskStoppedError): - _run(_runner(client, store), _StoppedQueueManager()) + _run(_runner(client, store, human_forms=human_forms, app_records=app_records), _StoppedQueueManager()) assert client.cancel_after == [None] assert store.saved == [] @@ -1612,14 +1674,14 @@ def test_terminal_output_to_answer_handles_plain_string_and_dict() -> None: assert AgentAppRunner._terminal_output_to_answer({"a": 1}) == '{"a": 1}' -def test_ask_human_pauses_turn_creates_form_and_persists_correlation() -> None: +def test_ask_human_pauses_turn_creates_form_and_persists_correlation(app_records, human_forms) -> None: # ENG-635/637: the PAUSED scenario emits a dify.ask_human deferred call, so # the chat turn ends by creating a conversation-owned HITL form + saving the # pause correlation, instead of crashing. Stub the form repo (DB-free). client = _UsagePausedClient() store = _FakeSessionStore() qm = _FakeQueueManager() - runner = _runner(client, store) + runner = _runner(client, store, human_forms=human_forms, app_records=app_records) fake_repo = MagicMock() fake_repo.create_form.return_value = MagicMock(id="form-1") @@ -1641,7 +1703,9 @@ def test_ask_human_pauses_turn_creates_form_and_persists_correlation() -> None: assert store.saved[0][4] == "fake-ask-human-1" -def test_submitted_form_resumes_turn_with_deferred_tool_results(monkeypatch: pytest.MonkeyPatch) -> None: +def test_submitted_form_resumes_turn_with_deferred_tool_results( + monkeypatch: pytest.MonkeyPatch, *, app_records, human_forms +) -> None: # ENG-638: a turn that runs while a pending form is answered threads the # human's reply into the request as deferred_tool_results. snapshot = _compatible_session_snapshot() @@ -1664,13 +1728,13 @@ def test_submitted_form_resumes_turn_with_deferred_tool_results(monkeypatch: pyt store = _FakeSessionStore(loaded_session=stored) submitted = AskHumanResumeOutcome(deferred_result=AskHumanToolResult(status="submitted", values={"ok": True})) monkeypatch.setattr( - "core.app.apps.agent_app.app_runner.resolve_ask_human_form", + "services.app.generation.adapters.agent_runner.resolve_ask_human_form", lambda **_kwargs: submitted, ) client = FakeAgentBackendRunClient() # SUCCESS -> the resumed run completes qm = _FakeQueueManager() - _run(_runner(client, store), qm) + _run(_runner(client, store, human_forms=human_forms, app_records=app_records), qm) assert client.request is not None assert client.request.deferred_tool_results is not None diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py b/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py index eaf50049612f76..020be83c215a3e 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py @@ -10,19 +10,23 @@ from types import SimpleNamespace from typing import Any +from unittest.mock import Mock import pytest from sqlalchemy.orm import Session -import core.app.features.annotation_reply.annotation_reply as annotation_mod import core.moderation.input_moderation as input_moderation_mod -from core.app.apps.agent_app.app_generator import AgentAppGenerator +import services.annotation.reply_service as annotation_mod +from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.queue_entities import ( QueueLLMChunkEvent, QueueMessageEndEvent, ) from core.moderation.base import ModerationError from models.model import App, AppMode, Message, MessageAnnotation +from models.tool_runtime_contracts import WorkflowToolQueries +from services.app.generation.adapters.agent_app import AgentAppGenerator +from services.app.generation.agent_config import AgentAppConfigurations from tests.unit_tests.model_factories import make_app, make_message @@ -42,7 +46,7 @@ def _make_entity(query: str = "hello") -> SimpleNamespace: query=query, trace_manager=None, user_id="user-1", - invoke_from=SimpleNamespace(), + invoke_from=InvokeFrom.WEB_APP, ) @@ -69,7 +73,7 @@ class _FakeAnnotation: def query(self, **kwargs: Any): return reply - monkeypatch.setattr(annotation_mod, "AnnotationReplyFeature", _FakeAnnotation) + monkeypatch.setattr(annotation_mod.AnnotationReplyService, "query", _FakeAnnotation.query) def _answer_text(events: list[Any]) -> str: @@ -85,13 +89,28 @@ def _saved_user_query(events: list[Any]) -> str: class TestRunInputGuards: - def test_no_guards_passes_through(self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): + def test_no_guards_passes_through( + self, + tool_providers, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + *, + annotation_replies, + app_records, + human_forms, + ): _patch_moderation(monkeypatch, returns=(False, {}, "hello")) _patch_annotation(monkeypatch, reply=None) qm = _FakeQueueManager() - handled, query, annotation_reply = AgentAppGenerator()._run_input_guards( - session=sqlite_session, + handled, query, annotation_reply = AgentAppGenerator( + agent_configs=Mock(spec=AgentAppConfigurations), + forms=human_forms, + annotations=annotation_replies, + records=app_records, + tool_providers=tool_providers, + workflow_queries=Mock(spec=WorkflowToolQueries), + )._run_input_guards( application_generate_entity=_make_entity("hello"), app_model=_app(), message=_message(), @@ -103,13 +122,28 @@ def test_no_guards_passes_through(self, monkeypatch: pytest.MonkeyPatch, sqlite_ assert annotation_reply is None assert qm.events == [] - def test_moderation_override_sanitizes_query(self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): + def test_moderation_override_sanitizes_query( + self, + tool_providers, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + *, + annotation_replies, + app_records, + human_forms, + ): _patch_moderation(monkeypatch, returns=(True, {}, "[redacted]")) _patch_annotation(monkeypatch, reply=None) qm = _FakeQueueManager() - handled, query, annotation_reply = AgentAppGenerator()._run_input_guards( - session=sqlite_session, + handled, query, annotation_reply = AgentAppGenerator( + agent_configs=Mock(spec=AgentAppConfigurations), + forms=human_forms, + annotations=annotation_replies, + records=app_records, + tool_providers=tool_providers, + workflow_queries=Mock(spec=WorkflowToolQueries), + )._run_input_guards( application_generate_entity=_make_entity("leak my secret"), app_model=_app(), message=_message(), @@ -121,13 +155,28 @@ def test_moderation_override_sanitizes_query(self, monkeypatch: pytest.MonkeyPat assert annotation_reply is None assert qm.events == [] - def test_moderation_block_short_circuits(self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): + def test_moderation_block_short_circuits( + self, + tool_providers, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + *, + annotation_replies, + app_records, + human_forms, + ): _patch_moderation(monkeypatch, raises=ModerationError("blocked preset answer")) _patch_annotation(monkeypatch, reply=None) qm = _FakeQueueManager() - handled, _, annotation_reply = AgentAppGenerator()._run_input_guards( - session=sqlite_session, + handled, _, annotation_reply = AgentAppGenerator( + agent_configs=Mock(spec=AgentAppConfigurations), + forms=human_forms, + annotations=annotation_replies, + records=app_records, + tool_providers=tool_providers, + workflow_queries=Mock(spec=WorkflowToolQueries), + )._run_input_guards( application_generate_entity=_make_entity("forbidden"), app_model=_app(), message=_message(), @@ -140,7 +189,16 @@ def test_moderation_block_short_circuits(self, monkeypatch: pytest.MonkeyPatch, assert _answer_text(qm.events) == "blocked preset answer" assert _saved_user_query(qm.events) == "forbidden" - def test_annotation_hit_short_circuits(self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): + def test_annotation_hit_short_circuits( + self, + tool_providers, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + *, + annotation_replies, + app_records, + human_forms, + ): _patch_moderation(monkeypatch, returns=(False, {}, "what is your name")) annotation = MessageAnnotation( app_id="app-1", @@ -152,8 +210,14 @@ def test_annotation_hit_short_circuits(self, monkeypatch: pytest.MonkeyPatch, sq _patch_annotation(monkeypatch, reply=annotation) qm = _FakeQueueManager() - handled, _, annotation_reply = AgentAppGenerator()._run_input_guards( - session=sqlite_session, + handled, _, annotation_reply = AgentAppGenerator( + agent_configs=Mock(spec=AgentAppConfigurations), + forms=human_forms, + annotations=annotation_replies, + records=app_records, + tool_providers=tool_providers, + workflow_queries=Mock(spec=WorkflowToolQueries), + )._run_input_guards( application_generate_entity=_make_entity("what is your name"), app_model=_app(), message=_message(), diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py index b93f0e6ff90b96..f29b74a437f6cf 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_runtime_request_builder.py @@ -23,24 +23,24 @@ AgentBackendRunRequestBuilder, ) from clients.agent_backend.request_builder import DIFY_SHELL_LAYER_ID -from core.app.apps.agent_app.errors import AgentSessionSnapshotIncompatibleError -from core.app.apps.agent_app.runtime_request_builder import ( - AgentAppRuntimeBuildContext, - AgentAppRuntimeRequestBuilder, - AgentAppRuntimeRequestBuildError, -) from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from core.workflow.file_reference import build_file_reference from graphon.file import File, FileTransferMethod, FileType from graphon.model_runtime.entities.message_entities import ImagePromptMessageContent from models.agent_config_entities import AgentSoulConfig +from services.app.generation.adapters.agent_request_builder import ( + AgentAppRuntimeBuildContext, + AgentAppRuntimeRequestBuilder, + AgentAppRuntimeRequestBuildError, +) +from services.app.generation.errors import AgentSessionSnapshotIncompatibleError from tests.unit_tests.config_override import apply_config_overrides @pytest.fixture(autouse=True) def _no_runtime_agent_skills(monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( - "core.app.apps.agent_app.runtime_request_builder.load_runtime_agent_skill_configs", + "services.app.generation.adapters.agent_request_builder.load_runtime_agent_skill_configs", lambda **_kwargs: [], ) @@ -54,7 +54,7 @@ def resolve(*, run_context: object, provider_name: str, model_name: str) -> int: return 32_768 monkeypatch.setattr( - "core.app.apps.agent_app.runtime_request_builder.resolve_model_context_window", + "services.app.generation.adapters.agent_request_builder.resolve_model_context_window", resolve, ) return calls @@ -287,7 +287,7 @@ def test_build_maps_soul_to_run_request(self, model_context_window_calls: list[t def test_build_sends_images_directly_to_vision_model(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( - "core.app.apps.agent_app.runtime_request_builder.resolve_model_supports_vision", + "services.app.generation.adapters.agent_request_builder.resolve_model_supports_vision", lambda **_kwargs: True, ) prompt_content_calls: list[tuple[File, ImagePromptMessageContent.DETAIL | None]] = [] @@ -307,7 +307,7 @@ def to_prompt_message_content( ) monkeypatch.setattr( - "core.app.apps.agent_app.runtime_request_builder.file_manager.to_prompt_message_content", + "services.app.generation.adapters.agent_request_builder.file_manager.to_prompt_message_content", to_prompt_message_content, ) builder = AgentAppRuntimeRequestBuilder(dify_tools_builder=_NoToolsBuilder()) # type: ignore[arg-type] @@ -334,7 +334,7 @@ def to_prompt_message_content( def test_build_keeps_image_locator_for_non_vision_model(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( - "core.app.apps.agent_app.runtime_request_builder.resolve_model_supports_vision", + "services.app.generation.adapters.agent_request_builder.resolve_model_supports_vision", lambda **_kwargs: False, ) builder = AgentAppRuntimeRequestBuilder(dify_tools_builder=_NoToolsBuilder()) # type: ignore[arg-type] @@ -355,11 +355,11 @@ def test_build_keeps_image_locator_for_non_vision_model(self, monkeypatch: pytes def test_build_preserves_inline_base64_transport_for_vision_model(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( - "core.app.apps.agent_app.runtime_request_builder.resolve_model_supports_vision", + "services.app.generation.adapters.agent_request_builder.resolve_model_supports_vision", lambda **_kwargs: True, ) monkeypatch.setattr( - "core.app.apps.agent_app.runtime_request_builder.file_manager.to_prompt_message_content", + "services.app.generation.adapters.agent_request_builder.file_manager.to_prompt_message_content", lambda *_args, **_kwargs: ImagePromptMessageContent( format="png", base64_data="aW1hZ2UtYnl0ZXM=", @@ -381,11 +381,11 @@ def test_build_preserves_inline_base64_transport_for_vision_model(self, monkeypa def test_build_keeps_non_image_locator_when_vision_image_is_direct(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( - "core.app.apps.agent_app.runtime_request_builder.resolve_model_supports_vision", + "services.app.generation.adapters.agent_request_builder.resolve_model_supports_vision", lambda **_kwargs: True, ) monkeypatch.setattr( - "core.app.apps.agent_app.runtime_request_builder.file_manager.to_prompt_message_content", + "services.app.generation.adapters.agent_request_builder.file_manager.to_prompt_message_content", lambda *_args, **_kwargs: ImagePromptMessageContent( format="png", url="https://files.example.com/earth.png", @@ -762,7 +762,7 @@ def test_config_layer_for_build_draft_marks_config_writable(self): def test_config_layer_includes_bound_workspace_skills(self, monkeypatch: pytest.MonkeyPatch): monkeypatch.setattr( - "core.app.apps.agent_app.runtime_request_builder.load_runtime_agent_skill_configs", + "services.app.generation.adapters.agent_request_builder.load_runtime_agent_skill_configs", lambda **_kwargs: [ DifyConfigSkillConfig( name="workspace-skill", diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py b/api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py index 54850885428dae..812d7820209250 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_session_store.py @@ -11,6 +11,7 @@ AgentWorkspaceOwnerType, ) from models.model import App, AppMode, Conversation +from repositories.agent_workspace_repository import AgentWorkspaceRepository from services.agent.workspace_service import AgentWorkspaceNotFoundError, AgentWorkspaceService @@ -136,7 +137,7 @@ def test_load_or_create_uses_exact_caller_binding( create = MagicMock() store = AgentAppWorkspaceStore() monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", get_binding) - monkeypatch.setattr(AgentWorkspaceService, "validate_binding_generation", MagicMock()) + monkeypatch.setattr(AgentWorkspaceRepository, "validate_binding_generation", MagicMock()) monkeypatch.setattr(AgentWorkspaceService, "create_binding", create) stored = store.load_or_create(_scope()) diff --git a/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_generator.py b/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_generator.py index fcca5f5bc09166..fbdeef8d3251e5 100644 --- a/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_generator.py @@ -8,13 +8,13 @@ from pytest_mock import MockerFixture from sqlalchemy.orm import Session -from core.app.apps.agent_chat.app_generator import AgentChatAppGenerator from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import InvokeFrom from graphon.model_runtime.errors.invoke import InvokeAuthorizationError from models import Account from models.enums import ConversationFromSource from models.model import App, AppMode, AppModelConfig, Conversation, Message +from services.app.generation.adapters.agent_chat import AgentChatAppGenerator from tests.unit_tests.config_override import apply_config_overrides @@ -81,13 +81,17 @@ def _message() -> Message: @pytest.fixture -def generator(mocker: MockerFixture): - gen = AgentChatAppGenerator() +def generator(app_records, mocker: MockerFixture, *, annotation_replies, workflow_runtime): + gen = AgentChatAppGenerator( + dataset_tools=workflow_runtime.dataset_tools, + annotations=annotation_replies, + records=app_records, + ) mocker.patch( - "core.app.apps.agent_chat.app_generator.current_app", + "services.app.generation.adapters.agent_chat.current_app", new=mocker.MagicMock(_get_current_object=mocker.MagicMock()), ) - mocker.patch("core.app.apps.agent_chat.app_generator.contextvars.copy_context", return_value="ctx") + mocker.patch("services.app.generation.adapters.agent_chat.contextvars.copy_context", return_value="ctx") return gen @@ -156,53 +160,53 @@ def test_generate_success_with_debugger_override(self, generator, mocker: Mocker generator._handle_response = mocker.MagicMock(return_value="response") mocker.patch( - "core.app.apps.agent_chat.app_generator.AgentChatAppConfigManager.config_validate", + "services.app.generation.adapters.agent_chat.AgentChatAppConfigManager.config_validate", return_value={"validated": True}, ) app_config = mocker.MagicMock(variables={}, prompt_template=mocker.MagicMock(), external_data_variables=[]) mocker.patch( - "core.app.apps.agent_chat.app_generator.AgentChatAppConfigManager.get_app_config", + "services.app.generation.adapters.agent_chat.AgentChatAppConfigManager.get_app_config", return_value=app_config, ) mocker.patch( - "core.app.apps.agent_chat.app_generator.ModelConfigConverter.convert", + "services.app.generation.adapters.agent_chat.ModelConfigConverter.convert", return_value=mocker.MagicMock(), ) mocker.patch( - "core.app.apps.agent_chat.app_generator.FileUploadConfigManager.convert", + "services.app.generation.adapters.agent_chat.FileUploadConfigManager.convert", return_value=mocker.MagicMock(), ) mocker.patch( - "core.app.apps.agent_chat.app_generator.file_factory.build_from_mappings", + "services.app.generation.adapters.agent_chat.file_factory.build_from_mappings", return_value=["file-obj"], ) mocker.patch( - "core.app.apps.agent_chat.app_generator.ConversationService.get_conversation", + "repositories.app.generation_repository.AppGenerationRepository.conversation", return_value=_conversation(), ) mocker.patch( - "core.app.apps.agent_chat.app_generator.TraceQueueManager", + "services.app.generation.adapters.agent_chat.TraceQueueManager", return_value=mocker.MagicMock(), ) queue_manager = mocker.MagicMock() mocker.patch( - "core.app.apps.agent_chat.app_generator.MessageBasedAppQueueManager", + "services.app.generation.adapters.agent_chat.MessageBasedAppQueueManager", return_value=queue_manager, ) thread_obj = mocker.MagicMock() thread_constructor = mocker.patch( - "core.app.apps.agent_chat.app_generator.threading.Thread", + "services.app.generation.adapters.agent_chat.threading.Thread", return_value=thread_obj, ) mocker.patch( - "core.app.apps.agent_chat.app_generator.AgentChatAppGenerateResponseConverter.convert", + "services.app.generation.adapters.agent_chat.AgentChatAppGenerateResponseConverter.convert", return_value={"result": "ok"}, ) app_entity = mocker.MagicMock(task_id="task", user_id="user", invoke_from=invoke_from) generate_entity = mocker.patch( - "core.app.apps.agent_chat.app_generator.AgentChatAppGenerateEntity", + "services.app.generation.adapters.agent_chat.AgentChatAppGenerateEntity", return_value=app_entity, ) @@ -226,8 +230,8 @@ def test_generate_success_with_debugger_override(self, generator, mocker: Mocker ) assert result == {"result": "ok"} - assert generator._get_app_model_config.call_args.kwargs["session"] is session - assert generator._init_generate_records.call_args.kwargs["session"] is session + assert "session" not in generator._get_app_model_config.call_args.kwargs + assert "session" not in generator._init_generate_records.call_args.kwargs assert generate_entity.call_args.kwargs["extras"]["trace_session_id"] == "session-1" worker_call = thread_constructor.call_args inspect.signature(worker_call.kwargs["target"]).bind(**worker_call.kwargs["kwargs"]) @@ -248,48 +252,48 @@ def test_generate_without_file_config(self, generator, mocker: MockerFixture, sq to_dict = mocker.patch.object(AppModelConfig, "to_dict", return_value={"model": {"provider": "p"}}) load_annotation_reply_config = mocker.patch( - "core.app.apps.agent_chat.app_generator.load_annotation_reply_config", + "repositories.app.generation_repository.AppGenerationRepository.annotation_config", return_value=annotation_reply, ) get_app_config = mocker.patch( - "core.app.apps.agent_chat.app_generator.AgentChatAppConfigManager.get_app_config", + "services.app.generation.adapters.agent_chat.AgentChatAppConfigManager.get_app_config", return_value=mocker.MagicMock(variables={}, prompt_template=mocker.MagicMock(), external_data_variables=[]), ) mocker.patch( - "core.app.apps.agent_chat.app_generator.ModelConfigConverter.convert", + "services.app.generation.adapters.agent_chat.ModelConfigConverter.convert", return_value=mocker.MagicMock(), ) mocker.patch( - "core.app.apps.agent_chat.app_generator.FileUploadConfigManager.convert", + "services.app.generation.adapters.agent_chat.FileUploadConfigManager.convert", return_value=None, ) mocker.patch( - "core.app.apps.agent_chat.app_generator.file_factory.build_from_mappings", + "services.app.generation.adapters.agent_chat.file_factory.build_from_mappings", return_value=["file-obj"], ) mocker.patch( - "core.app.apps.agent_chat.app_generator.TraceQueueManager", + "services.app.generation.adapters.agent_chat.TraceQueueManager", return_value=mocker.MagicMock(), ) mocker.patch( - "core.app.apps.agent_chat.app_generator.MessageBasedAppQueueManager", + "services.app.generation.adapters.agent_chat.MessageBasedAppQueueManager", return_value=mocker.MagicMock(), ) thread_obj = mocker.MagicMock() mocker.patch( - "core.app.apps.agent_chat.app_generator.threading.Thread", + "services.app.generation.adapters.agent_chat.threading.Thread", return_value=thread_obj, ) mocker.patch( - "core.app.apps.agent_chat.app_generator.AgentChatAppGenerateResponseConverter.convert", + "services.app.generation.adapters.agent_chat.AgentChatAppGenerateResponseConverter.convert", return_value={"result": "ok"}, ) app_entity = mocker.MagicMock(task_id="task", user_id="user", invoke_from=InvokeFrom.WEB_APP) mocker.patch( - "core.app.apps.agent_chat.app_generator.AgentChatAppGenerateEntity", + "services.app.generation.adapters.agent_chat.AgentChatAppGenerateEntity", return_value=app_entity, ) @@ -306,7 +310,7 @@ def test_generate_without_file_config(self, generator, mocker: MockerFixture, sq ) assert result == {"result": "ok"} - load_annotation_reply_config.assert_called_once_with(session, "app1") + load_annotation_reply_config.assert_called_once_with(tenant_id="tenant", app_id="app1") to_dict.assert_called_once_with(annotation_reply=annotation_reply) assert get_app_config.call_args.kwargs["annotation_reply"] is annotation_reply @@ -318,16 +322,15 @@ def patch_context(self, mocker: MockerFixture): def ctx_manager[**P](*args: P.args, **kwargs: P.kwargs): yield - mocker.patch("core.app.apps.agent_chat.app_generator.preserve_flask_contexts", ctx_manager) + mocker.patch("services.app.generation.adapters.agent_chat.preserve_flask_contexts", ctx_manager) def test_generate_worker_handles_generate_task_stopped(self, generator, mocker: MockerFixture): queue_manager = mocker.MagicMock() - generator._get_conversation = mocker.MagicMock(return_value=_conversation()) - generator._get_message = mocker.MagicMock(return_value=_message()) + mocker.patch.object(generator._records, "load", return_value=(_app(), _conversation(), _message())) runner = mocker.MagicMock() runner.run.side_effect = GenerateTaskStoppedError() - mocker.patch("core.app.apps.agent_chat.app_generator.AgentChatAppRunner", return_value=runner) + mocker.patch("services.app.generation.adapters.agent_chat.AgentChatAppRunner", return_value=runner) generator._generate_worker( flask_app=mocker.MagicMock(), @@ -351,12 +354,11 @@ def test_generate_worker_handles_generate_task_stopped(self, generator, mocker: ) def test_generate_worker_publishes_errors(self, generator, mocker: MockerFixture, error): queue_manager = mocker.MagicMock() - generator._get_conversation = mocker.MagicMock(return_value=_conversation()) - generator._get_message = mocker.MagicMock(return_value=_message()) + mocker.patch.object(generator._records, "load", return_value=(_app(), _conversation(), _message())) runner = mocker.MagicMock() runner.run.side_effect = error - mocker.patch("core.app.apps.agent_chat.app_generator.AgentChatAppRunner", return_value=runner) + mocker.patch("services.app.generation.adapters.agent_chat.AgentChatAppRunner", return_value=runner) generator._generate_worker( flask_app=mocker.MagicMock(), @@ -377,16 +379,15 @@ def test_generate_worker_logs_value_error_when_debug( caplog: pytest.LogCaptureFixture, ): queue_manager = mocker.MagicMock() - generator._get_conversation = mocker.MagicMock(return_value=_conversation()) - generator._get_message = mocker.MagicMock(return_value=_message()) + mocker.patch.object(generator._records, "load", return_value=(_app(), _conversation(), _message())) runner = mocker.MagicMock() runner.run.side_effect = ValueError("bad") - mocker.patch("core.app.apps.agent_chat.app_generator.AgentChatAppRunner", return_value=runner) + mocker.patch("services.app.generation.adapters.agent_chat.AgentChatAppRunner", return_value=runner) apply_config_overrides(monkeypatch, DEBUG=True) - with caplog.at_level(logging.ERROR, logger="core.app.apps.agent_chat.app_generator"): + with caplog.at_level(logging.ERROR, logger="services.app.generation.adapters.agent_chat"): generator._generate_worker( flask_app=mocker.MagicMock(), context=mocker.MagicMock(), diff --git a/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_runner.py b/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_runner.py index 1c1ee3663ddd99..f4cdffd9a32b67 100644 --- a/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_runner.py @@ -6,17 +6,17 @@ from sqlalchemy.orm import Session from core.agent.entities import AgentEntity -from core.app.apps.agent_chat.app_runner import AgentChatAppRunner from core.app.entities.app_invoke_entities import InvokeFrom from core.moderation.base import ModerationError from graphon.model_runtime.entities.llm_entities import LLMMode from graphon.model_runtime.entities.model_entities import ModelFeature, ModelPropertyKey from models.enums import ConversationFromSource from models.model import App, AppMode, Conversation, Message, MessageAnnotation +from services.app.generation.adapters.agent_chat_runner import AgentChatAppRunner @pytest.fixture -def runner(sqlite_session: Session): +def runner(sqlite_session: Session, *, app_records, workflow_runtime): app = App( id="app1", tenant_id="tenant", @@ -71,7 +71,10 @@ def runner(sqlite_session: Session): ) sqlite_session.add_all([app, conversation, message]) sqlite_session.commit() - return AgentChatAppRunner() + return AgentChatAppRunner( + dataset_tools=workflow_runtime.dataset_tools, + records=app_records, + ) def _records(session: Session) -> tuple[Conversation, Message]: @@ -83,21 +86,8 @@ def _records(session: Session) -> tuple[Conversation, Message]: class TestAgentChatAppRunnerRun: - def test_run_app_not_found(self, runner: AgentChatAppRunner, mocker: MockerFixture, sqlite_session: Session): - app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", agent=mocker.MagicMock()) - generate_entity = mocker.MagicMock(app_config=app_config, inputs={}, query="q", files=[], stream=True) - - app = sqlite_session.get(App, "app1") - assert app is not None - sqlite_session.delete(app) - sqlite_session.commit() - conversation, message = _records(sqlite_session) - - with pytest.raises(ValueError): - runner.run(generate_entity, mocker.MagicMock(), conversation, message, sqlite_session) - def test_run_moderation_error_direct_output( - self, runner: AgentChatAppRunner, mocker: MockerFixture, sqlite_session: Session + self, runner: AgentChatAppRunner, mocker: MockerFixture, sqlite_session: Session, annotation_replies ): app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock()) app_config.agent = mocker.MagicMock() @@ -116,12 +106,19 @@ def test_run_moderation_error_direct_output( mocker.patch.object(runner, "direct_output") conversation, message = _records(sqlite_session) - runner.run(generate_entity, mocker.MagicMock(), conversation, message, sqlite_session) + runner.run( + generate_entity, + mocker.MagicMock(), + conversation, + message, + app_record=sqlite_session.get(App, "app1"), + annotations=annotation_replies, + ) runner.direct_output.assert_called_once() def test_run_annotation_reply_short_circuits( - self, runner: AgentChatAppRunner, mocker: MockerFixture, sqlite_session: Session + self, runner: AgentChatAppRunner, mocker: MockerFixture, sqlite_session: Session, annotation_replies ): app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock()) app_config.agent = mocker.MagicMock() @@ -145,20 +142,27 @@ def test_run_annotation_reply_short_circuits( content="answer", account_id="user", ) - annotation_query = mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=annotation) + annotation_query = mocker.patch.object(annotation_replies, "query", return_value=annotation) mocker.patch.object(runner, "direct_output") queue_manager = mocker.MagicMock() write_session = sqlite_session conversation, message = _records(sqlite_session) - runner.run(generate_entity, queue_manager, conversation, message, write_session) + runner.run( + generate_entity, + queue_manager, + conversation, + message, + app_record=sqlite_session.get(App, "app1"), + annotations=annotation_replies, + ) queue_manager.publish.assert_called_once() - assert annotation_query.call_args.kwargs["session"] is write_session + assert annotation_query.call_args.kwargs["app_id"] == "app1" runner.direct_output.assert_called_once() def test_run_hosting_moderation_short_circuits( - self, runner: AgentChatAppRunner, mocker: MockerFixture, sqlite_session: Session + self, runner: AgentChatAppRunner, mocker: MockerFixture, sqlite_session: Session, annotation_replies ): app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock()) app_config.agent = mocker.MagicMock() @@ -176,13 +180,22 @@ def test_run_hosting_moderation_short_circuits( mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None)) mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q")) - mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None) + mocker.patch.object(annotation_replies, "query", return_value=None) mocker.patch.object(runner, "check_hosting_moderation", return_value=True) conversation, message = _records(sqlite_session) - runner.run(generate_entity, mocker.MagicMock(), conversation, message, sqlite_session) + runner.run( + generate_entity, + mocker.MagicMock(), + conversation, + message, + app_record=sqlite_session.get(App, "app1"), + annotations=annotation_replies, + ) - def test_run_model_schema_missing(self, runner: AgentChatAppRunner, mocker: MockerFixture, sqlite_session: Session): + def test_run_model_schema_missing( + self, runner: AgentChatAppRunner, mocker: MockerFixture, sqlite_session: Session, annotation_replies + ): app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock()) app_config.agent = AgentEntity(provider="p", model="m", strategy=AgentEntity.Strategy.CHAIN_OF_THOUGHT) @@ -205,16 +218,23 @@ def test_run_model_schema_missing(self, runner: AgentChatAppRunner, mocker: Mock mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None)) mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q")) - mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None) + mocker.patch.object(annotation_replies, "query", return_value=None) mocker.patch.object(runner, "check_hosting_moderation", return_value=False) llm_instance = mocker.MagicMock() llm_instance.model_type_instance.get_model_schema.return_value = None - mocker.patch("core.app.apps.agent_chat.app_runner.ModelInstance", return_value=llm_instance) + mocker.patch("services.app.generation.adapters.agent_chat_runner.ModelInstance", return_value=llm_instance) conversation, message = _records(sqlite_session) with pytest.raises(ValueError): - runner.run(generate_entity, mocker.MagicMock(), conversation, message, sqlite_session) + runner.run( + generate_entity, + mocker.MagicMock(), + conversation, + message, + app_record=sqlite_session.get(App, "app1"), + annotations=annotation_replies, + ) @pytest.mark.parametrize( ("mode", "expected_runner"), @@ -230,6 +250,7 @@ def test_run_chain_of_thought_modes( mode, expected_runner, sqlite_session: Session, + annotation_replies, ): app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock()) app_config.agent = AgentEntity(provider="p", model="m", strategy=AgentEntity.Strategy.CHAIN_OF_THOUGHT) @@ -253,7 +274,7 @@ def test_run_chain_of_thought_modes( mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None)) mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q")) - mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None) + mocker.patch.object(annotation_replies, "query", return_value=None) mocker.patch.object(runner, "check_hosting_moderation", return_value=False) model_schema = mocker.MagicMock() @@ -262,12 +283,12 @@ def test_run_chain_of_thought_modes( llm_instance = mocker.MagicMock() llm_instance.model_type_instance.get_model_schema.return_value = model_schema - mocker.patch("core.app.apps.agent_chat.app_runner.ModelInstance", return_value=llm_instance) + mocker.patch("services.app.generation.adapters.agent_chat_runner.ModelInstance", return_value=llm_instance) conversation, message = _records(sqlite_session) runner_cls = mocker.MagicMock() - mocker.patch(f"core.app.apps.agent_chat.app_runner.{expected_runner}", runner_cls) + mocker.patch(f"services.app.generation.adapters.agent_chat_runner.{expected_runner}", runner_cls) runner_instance = mocker.MagicMock() runner_cls.return_value = runner_instance @@ -281,16 +302,23 @@ def record_commit(_session: Session) -> None: event.listen(session, "after_commit", record_commit) try: - runner.run(generate_entity, mocker.MagicMock(), conversation, message, session) + runner.run( + generate_entity, + mocker.MagicMock(), + conversation, + message, + app_record=sqlite_session.get(App, "app1"), + annotations=annotation_replies, + ) finally: event.remove(session, "after_commit", record_commit) - assert events == ["commit", "commit", "agent-run"] + assert events == ["agent-run"] runner_instance.run.assert_called_once() runner._handle_invoke_result.assert_called_once() def test_run_invalid_llm_mode_raises( - self, runner: AgentChatAppRunner, mocker: MockerFixture, sqlite_session: Session + self, runner: AgentChatAppRunner, mocker: MockerFixture, sqlite_session: Session, annotation_replies ): app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock()) app_config.agent = AgentEntity(provider="p", model="m", strategy=AgentEntity.Strategy.CHAIN_OF_THOUGHT) @@ -314,7 +342,7 @@ def test_run_invalid_llm_mode_raises( mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None)) mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q")) - mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None) + mocker.patch.object(annotation_replies, "query", return_value=None) mocker.patch.object(runner, "check_hosting_moderation", return_value=False) model_schema = mocker.MagicMock() @@ -323,15 +351,22 @@ def test_run_invalid_llm_mode_raises( llm_instance = mocker.MagicMock() llm_instance.model_type_instance.get_model_schema.return_value = model_schema - mocker.patch("core.app.apps.agent_chat.app_runner.ModelInstance", return_value=llm_instance) + mocker.patch("services.app.generation.adapters.agent_chat_runner.ModelInstance", return_value=llm_instance) conversation, message = _records(sqlite_session) with pytest.raises(ValueError): - runner.run(generate_entity, mocker.MagicMock(), conversation, message, sqlite_session) + runner.run( + generate_entity, + mocker.MagicMock(), + conversation, + message, + app_record=sqlite_session.get(App, "app1"), + annotations=annotation_replies, + ) def test_run_function_calling_strategy_selected_by_features( - self, runner: AgentChatAppRunner, mocker: MockerFixture, sqlite_session: Session + self, runner: AgentChatAppRunner, mocker: MockerFixture, sqlite_session: Session, annotation_replies ): app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock()) app_config.agent = AgentEntity(provider="p", model="m", strategy=AgentEntity.Strategy.CHAIN_OF_THOUGHT) @@ -355,7 +390,7 @@ def test_run_function_calling_strategy_selected_by_features( mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None)) mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q")) - mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None) + mocker.patch.object(annotation_replies, "query", return_value=None) mocker.patch.object(runner, "check_hosting_moderation", return_value=False) model_schema = mocker.MagicMock() @@ -364,103 +399,32 @@ def test_run_function_calling_strategy_selected_by_features( llm_instance = mocker.MagicMock() llm_instance.model_type_instance.get_model_schema.return_value = model_schema - mocker.patch("core.app.apps.agent_chat.app_runner.ModelInstance", return_value=llm_instance) + mocker.patch("services.app.generation.adapters.agent_chat_runner.ModelInstance", return_value=llm_instance) conversation, message = _records(sqlite_session) runner_cls = mocker.MagicMock() - mocker.patch("core.app.apps.agent_chat.app_runner.FunctionCallAgentRunner", runner_cls) + mocker.patch("services.app.generation.adapters.agent_chat_runner.FunctionCallAgentRunner", runner_cls) runner_instance = mocker.MagicMock() runner_cls.return_value = runner_instance runner_instance.run.return_value = [] mocker.patch.object(runner, "_handle_invoke_result") - runner.run(generate_entity, mocker.MagicMock(), conversation, message, sqlite_session) + runner.run( + generate_entity, + mocker.MagicMock(), + conversation, + message, + app_record=sqlite_session.get(App, "app1"), + annotations=annotation_replies, + ) assert app_config.agent.strategy == AgentEntity.Strategy.FUNCTION_CALLING runner_instance.run.assert_called_once() - def test_run_conversation_not_found( - self, runner: AgentChatAppRunner, mocker: MockerFixture, sqlite_session: Session - ): - app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock()) - app_config.agent = AgentEntity(provider="p", model="m", strategy=AgentEntity.Strategy.FUNCTION_CALLING) - - generate_entity = mocker.MagicMock( - app_config=app_config, - inputs={}, - query="q", - files=[], - stream=True, - model_conf=mocker.MagicMock( - provider_model_bundle=mocker.MagicMock(), - model="m", - provider="p", - credentials={"k": "v"}, - ), - conversation_id="conv", - invoke_from=mocker.MagicMock(), - user_id="user", - ) - - conversation_record, message_record = _records(sqlite_session) - sqlite_session.delete(conversation_record) - sqlite_session.commit() - mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None)) - mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q")) - mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None) - mocker.patch.object(runner, "check_hosting_moderation", return_value=False) - - with pytest.raises(ValueError): - runner.run( - generate_entity, - mocker.MagicMock(), - conversation_record, - message_record, - sqlite_session, - ) - - def test_run_message_not_found(self, runner: AgentChatAppRunner, mocker: MockerFixture, sqlite_session: Session): - app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock()) - app_config.agent = AgentEntity(provider="p", model="m", strategy=AgentEntity.Strategy.FUNCTION_CALLING) - - generate_entity = mocker.MagicMock( - app_config=app_config, - inputs={}, - query="q", - files=[], - stream=True, - model_conf=mocker.MagicMock( - provider_model_bundle=mocker.MagicMock(), - model="m", - provider="p", - credentials={"k": "v"}, - ), - conversation_id="conv", - invoke_from=mocker.MagicMock(), - user_id="user", - ) - - conversation_record, message_record = _records(sqlite_session) - sqlite_session.delete(message_record) - sqlite_session.commit() - mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None)) - mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q")) - mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None) - mocker.patch.object(runner, "check_hosting_moderation", return_value=False) - - with pytest.raises(ValueError): - runner.run( - generate_entity, - mocker.MagicMock(), - conversation_record, - message_record, - sqlite_session, - ) - def test_run_invalid_agent_strategy_raises( - self, runner: AgentChatAppRunner, mocker: MockerFixture, sqlite_session: Session + self, runner: AgentChatAppRunner, mocker: MockerFixture, sqlite_session: Session, annotation_replies ): app_config = mocker.MagicMock(app_id="app1", tenant_id="tenant", prompt_template=mocker.MagicMock()) app_config.agent = mocker.MagicMock(strategy="invalid", provider="p", model="m") @@ -484,7 +448,7 @@ def test_run_invalid_agent_strategy_raises( mocker.patch.object(runner, "organize_prompt_messages", return_value=([], None)) mocker.patch.object(runner, "moderation_for_inputs", return_value=(None, {}, "q")) - mocker.patch.object(runner, "query_app_annotations_to_reply", return_value=None) + mocker.patch.object(annotation_replies, "query", return_value=None) mocker.patch.object(runner, "check_hosting_moderation", return_value=False) model_schema = mocker.MagicMock() @@ -493,9 +457,16 @@ def test_run_invalid_agent_strategy_raises( llm_instance = mocker.MagicMock() llm_instance.model_type_instance.get_model_schema.return_value = model_schema - mocker.patch("core.app.apps.agent_chat.app_runner.ModelInstance", return_value=llm_instance) + mocker.patch("services.app.generation.adapters.agent_chat_runner.ModelInstance", return_value=llm_instance) conversation, message = _records(sqlite_session) with pytest.raises(ValueError): - runner.run(generate_entity, mocker.MagicMock(), conversation, message, sqlite_session) + runner.run( + generate_entity, + mocker.MagicMock(), + conversation, + message, + app_record=sqlite_session.get(App, "app1"), + annotations=annotation_replies, + ) diff --git a/api/tests/unit_tests/core/app/apps/chat/test_app_generator_and_runner.py b/api/tests/unit_tests/core/app/apps/chat/test_app_generator_and_runner.py index d2ee53ca43d1e1..4bfc88d6375981 100644 --- a/api/tests/unit_tests/core/app/apps/chat/test_app_generator_and_runner.py +++ b/api/tests/unit_tests/core/app/apps/chat/test_app_generator_and_runner.py @@ -1,22 +1,21 @@ from decimal import Decimal from types import SimpleNamespace -from unittest.mock import ANY, MagicMock, Mock, patch +from unittest.mock import Mock, patch import pytest -from sqlalchemy import event from sqlalchemy.orm import Session -import core.app.apps.chat.app_generator as generator_module -from core.app.apps.chat.app_generator import ChatAppGenerator -from core.app.apps.chat.app_runner import ChatAppRunner from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.queue_entities import QueueAnnotationReplyEvent from core.moderation.base import ModerationError +from extensions.application_services.retrieval import build_dataset_retrieval from graphon.model_runtime.errors.invoke import InvokeAuthorizationError from models import Account from models.enums import ConversationFromSource from models.model import App, AppMode, AppModelConfig, Conversation, IconType, Message, MessageAnnotation +from services.app.generation.adapters.chat import ChatAppGenerator +from services.app.generation.adapters.chat_runner import ChatAppRunner class DummyGenerateEntity: @@ -113,14 +112,15 @@ def _persist_records(session: Session) -> tuple[App, Conversation, Message]: return app, conversation, message -@pytest.fixture(autouse=True) -def _bind_db_session(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(generator_module.db, "session", sqlite_session) - - class TestChatAppGenerator: - def test_generate_requires_query(self, unbound_session: Session): - generator = ChatAppGenerator() + def test_generate_requires_query( + self, app_records, unbound_session: Session, *, annotation_replies, sqlite_session_factory + ): + generator = ChatAppGenerator( + retrieval=build_dataset_retrieval(sqlite_session_factory), + annotations=annotation_replies, + records=app_records, + ) with pytest.raises(ValueError): generator.generate( session=unbound_session, @@ -131,8 +131,14 @@ def test_generate_requires_query(self, unbound_session: Session): streaming=False, ) - def test_generate_rejects_non_string_query(self, unbound_session: Session): - generator = ChatAppGenerator() + def test_generate_rejects_non_string_query( + self, app_records, unbound_session: Session, *, annotation_replies, sqlite_session_factory + ): + generator = ChatAppGenerator( + retrieval=build_dataset_retrieval(sqlite_session_factory), + annotations=annotation_replies, + records=app_records, + ) with pytest.raises(ValueError): generator.generate( session=unbound_session, @@ -143,8 +149,14 @@ def test_generate_rejects_non_string_query(self, unbound_session: Session): streaming=False, ) - def test_generate_debugger_overrides_model_config(self, unbound_session: Session): - generator = ChatAppGenerator() + def test_generate_debugger_overrides_model_config( + self, app_records, unbound_session: Session, *, annotation_replies, sqlite_session_factory + ): + generator = ChatAppGenerator( + retrieval=build_dataset_retrieval(sqlite_session_factory), + annotations=annotation_replies, + records=app_records, + ) app_model = _app() user = _account() args = { @@ -157,26 +169,27 @@ def test_generate_debugger_overrides_model_config(self, unbound_session: Session with ( patch( - "core.app.apps.chat.app_generator.ConversationService.get_conversation", return_value=None + "repositories.app.generation_repository.AppGenerationRepository.conversation", return_value=None ) as get_conversation, - patch("core.app.apps.chat.app_generator.ChatAppConfigManager.config_validate", return_value={"x": 1}), + patch("services.app.generation.adapters.chat.ChatAppConfigManager.config_validate", return_value={"x": 1}), patch( - "core.app.apps.chat.app_generator.ChatAppConfigManager.get_app_config", + "services.app.generation.adapters.chat.ChatAppConfigManager.get_app_config", return_value=SimpleNamespace( variables=[], external_data_variables=[], app_model_config_dict={}, app_mode=AppMode.CHAT ), ), - patch("core.app.apps.chat.app_generator.ModelConfigConverter.convert", return_value=SimpleNamespace()), - patch("core.app.apps.chat.app_generator.FileUploadConfigManager.convert", return_value=None), - patch("core.app.apps.chat.app_generator.file_factory.build_from_mappings", return_value=[]), + patch("services.app.generation.adapters.chat.ModelConfigConverter.convert", return_value=SimpleNamespace()), + patch("services.app.generation.adapters.chat.FileUploadConfigManager.convert", return_value=None), + patch("services.app.generation.adapters.chat.file_factory.build_from_mappings", return_value=[]), patch( - "core.app.apps.chat.app_generator.ChatAppGenerateEntity", + "services.app.generation.adapters.chat.ChatAppGenerateEntity", Mock(side_effect=DummyGenerateEntity), ) as generate_entity, - patch("core.app.apps.chat.app_generator.TraceQueueManager", return_value=SimpleNamespace()), - patch("core.app.apps.chat.app_generator.MessageBasedAppQueueManager", DummyQueueManager), + patch("services.app.generation.adapters.chat.TraceQueueManager", return_value=SimpleNamespace()), + patch("services.app.generation.adapters.chat.MessageBasedAppQueueManager", DummyQueueManager), patch( - "core.app.apps.chat.app_generator.ChatAppGenerateResponseConverter.convert", return_value={"ok": True} + "services.app.generation.adapters.chat.ChatAppGenerateResponseConverter.convert", + return_value={"ok": True}, ), patch.object(ChatAppGenerator, "_get_app_model_config", return_value=AppModelConfig(app_id="app-1")), patch.object(ChatAppGenerator, "_prepare_user_inputs", return_value={}), @@ -186,8 +199,8 @@ def test_generate_debugger_overrides_model_config(self, unbound_session: Session return_value=(_conversation(), _message()), ), patch.object(ChatAppGenerator, "_handle_response", return_value={"response": True}), - patch("core.app.apps.chat.app_generator.copy_current_request_context", side_effect=lambda f: f), - patch("core.app.apps.chat.app_generator.threading.Thread") as mock_thread, + patch("services.app.generation.adapters.chat.copy_current_request_context", side_effect=lambda f: f), + patch("services.app.generation.adapters.chat.threading.Thread") as mock_thread, ): mock_thread.return_value.start.return_value = None result = generator.generate( @@ -195,11 +208,17 @@ def test_generate_debugger_overrides_model_config(self, unbound_session: Session ) assert result == {"ok": True} - assert get_conversation.call_args.kwargs["session"] is unbound_session + assert get_conversation.call_args.kwargs["app_id"] == "app-1" assert generate_entity.call_args.kwargs["extras"]["trace_session_id"] == "session-1" - def test_generate_uses_session_for_annotation_reply(self, unbound_session: Session): - generator = ChatAppGenerator() + def test_generate_uses_session_for_annotation_reply( + self, app_records, unbound_session: Session, *, annotation_replies, sqlite_session_factory + ): + generator = ChatAppGenerator( + retrieval=build_dataset_retrieval(sqlite_session_factory), + annotations=annotation_replies, + records=app_records, + ) app_model = _app() app_model_config = AppModelConfig(app_id="app-1") annotation_reply = {"enabled": False} @@ -209,12 +228,12 @@ def test_generate_uses_session_for_annotation_reply(self, unbound_session: Sessi patch.object(ChatAppGenerator, "_get_app_model_config", return_value=app_model_config), patch.object(AppModelConfig, "to_dict", return_value={}) as to_dict, patch( - "core.app.apps.chat.app_generator.load_annotation_reply_config", + "repositories.app.generation_repository.AppGenerationRepository.annotation_config", return_value=annotation_reply, ) as load_annotation_reply_config, - patch("core.app.apps.chat.app_generator.FileUploadConfigManager.convert", return_value=None), + patch("services.app.generation.adapters.chat.FileUploadConfigManager.convert", return_value=None), patch( - "core.app.apps.chat.app_generator.ChatAppConfigManager.get_app_config", + "services.app.generation.adapters.chat.ChatAppConfigManager.get_app_config", side_effect=RuntimeError("stop after app config"), ) as get_app_config, ): @@ -227,12 +246,18 @@ def test_generate_uses_session_for_annotation_reply(self, unbound_session: Sessi session=unbound_session, ) - load_annotation_reply_config.assert_called_once_with(unbound_session, "app-1") + load_annotation_reply_config.assert_called_once_with(tenant_id="tenant-1", app_id="app-1") to_dict.assert_called_once_with(annotation_reply=annotation_reply) assert get_app_config.call_args.kwargs["annotation_reply"] is annotation_reply - def test_generate_rejects_model_config_override_for_non_debugger(self, unbound_session: Session): - generator = ChatAppGenerator() + def test_generate_rejects_model_config_override_for_non_debugger( + self, app_records, unbound_session: Session, *, annotation_replies, sqlite_session_factory + ): + generator = ChatAppGenerator( + retrieval=build_dataset_retrieval(sqlite_session_factory), + annotations=annotation_replies, + records=app_records, + ) with pytest.raises(ValueError): with ( patch.object(ChatAppGenerator, "_get_app_model_config", return_value=AppModelConfig(app_id="app-1")), @@ -247,15 +272,18 @@ def test_generate_rejects_model_config_override_for_non_debugger(self, unbound_s streaming=False, ) - def test_generate_worker_handles_exceptions(self): - generator = ChatAppGenerator() + def test_generate_worker_handles_exceptions(self, app_records, *, annotation_replies, sqlite_session_factory): + generator = ChatAppGenerator( + retrieval=build_dataset_retrieval(sqlite_session_factory), + annotations=annotation_replies, + records=app_records, + ) queue_manager = DummyQueueManager() - entity = DummyGenerateEntity(task_id="t1", user_id="u1") + entity = DummyGenerateEntity(task_id="t1", user_id="u1", app_config=Mock(app_id="app-1", tenant_id="tenant-1")) with ( - patch.object(ChatAppGenerator, "_get_conversation", return_value=_conversation()), - patch.object(ChatAppGenerator, "_get_message", return_value=_message()), - patch("core.app.apps.chat.app_generator.ChatAppRunner.run", side_effect=InvokeAuthorizationError()), + patch.object(app_records, "load", return_value=(_app(), _conversation(), _message())), + patch("services.app.generation.adapters.chat.ChatAppRunner.run", side_effect=InvokeAuthorizationError()), ): generator._generate_worker( flask_app=Mock(app_context=Mock(return_value=Mock(__enter__=Mock(), __exit__=Mock()))), @@ -268,9 +296,8 @@ def test_generate_worker_handles_exceptions(self): assert queue_manager.published with ( - patch.object(ChatAppGenerator, "_get_conversation", return_value=_conversation()), - patch.object(ChatAppGenerator, "_get_message", return_value=_message()), - patch("core.app.apps.chat.app_generator.ChatAppRunner.run", side_effect=GenerateTaskStoppedError()), + patch.object(app_records, "load", return_value=(_app(), _conversation(), _message())), + patch("services.app.generation.adapters.chat.ChatAppRunner.run", side_effect=GenerateTaskStoppedError()), ): generator._generate_worker( flask_app=Mock(app_context=Mock(return_value=Mock(__enter__=Mock(), __exit__=Mock()))), @@ -282,36 +309,14 @@ def test_generate_worker_handles_exceptions(self): class TestChatAppRunner: - def test_run_raises_when_app_missing(self, sqlite_session: Session): - runner = ChatAppRunner() - app_config = SimpleNamespace( - app_id="app-1", tenant_id="tenant-1", prompt_template=None, external_data_variables=[] - ) - app_generate_entity = DummyGenerateEntity( - app_config=app_config, - model_conf=SimpleNamespace(provider_model_bundle=None, model=None, parameters={}, app_model_config_dict={}), - inputs={}, - query="hi", - files=[], - file_upload_config=None, - conversation_id=None, - stream=False, - user_id="user-1", - invoke_from=InvokeFrom.SERVICE_API, - ) - - with pytest.raises(ValueError): - runner.run( - app_generate_entity, - DummyQueueManager(), - _conversation(), - _message(), - sqlite_session, - ) - - def test_run_moderation_error_direct_output(self, sqlite_session: Session): + def test_run_moderation_error_direct_output( + self, sqlite_session: Session, annotation_replies, *, app_records, sqlite_session_factory + ): _, conversation, message = _persist_records(sqlite_session) - runner = ChatAppRunner() + runner = ChatAppRunner( + retrieval=build_dataset_retrieval(sqlite_session_factory), + records=app_records, + ) app_config = SimpleNamespace( app_id="app-1", tenant_id="tenant-1", @@ -344,14 +349,20 @@ def test_run_moderation_error_direct_output(self, sqlite_session: Session): DummyQueueManager(), conversation, message, - sqlite_session, + app_record=sqlite_session.get(App, "app-1"), + annotations=annotation_replies, ) mock_direct.assert_called_once() - def test_run_annotation_reply_short_circuits(self, sqlite_session: Session): + def test_run_annotation_reply_short_circuits( + self, sqlite_session: Session, annotation_replies, *, app_records, sqlite_session_factory + ): _, conversation, message = _persist_records(sqlite_session) - runner = ChatAppRunner() + runner = ChatAppRunner( + retrieval=build_dataset_retrieval(sqlite_session_factory), + records=app_records, + ) app_config = SimpleNamespace( app_id="app-1", tenant_id="tenant-1", @@ -384,19 +395,31 @@ def test_run_annotation_reply_short_circuits(self, sqlite_session: Session): with ( patch.object(ChatAppRunner, "organize_prompt_messages", return_value=([], [])), patch.object(ChatAppRunner, "moderation_for_inputs", return_value=(None, {}, "hi")), - patch.object(ChatAppRunner, "query_app_annotations_to_reply", return_value=annotation) as annotation_query, + patch.object(annotation_replies, "query", return_value=annotation) as annotation_query, patch.object(ChatAppRunner, "direct_output") as mock_direct, ): queue_manager = DummyQueueManager() - runner.run(app_generate_entity, queue_manager, conversation, message, sqlite_session) + runner.run( + app_generate_entity, + queue_manager, + conversation, + message, + app_record=sqlite_session.get(App, "app-1"), + annotations=annotation_replies, + ) assert any(isinstance(item[0], QueueAnnotationReplyEvent) for item in queue_manager.published) - assert annotation_query.call_args.kwargs["session"] is sqlite_session + assert annotation_query.call_args.kwargs["app_id"] == "app-1" mock_direct.assert_called_once() - def test_run_returns_when_hosting_moderation_blocks(self, sqlite_session: Session): + def test_run_returns_when_hosting_moderation_blocks( + self, sqlite_session: Session, annotation_replies, *, app_records, sqlite_session_factory + ): _, conversation, message = _persist_records(sqlite_session) - runner = ChatAppRunner() + runner = ChatAppRunner( + retrieval=build_dataset_retrieval(sqlite_session_factory), + records=app_records, + ) app_config = SimpleNamespace( app_id="app-1", tenant_id="tenant-1", @@ -421,7 +444,7 @@ def test_run_returns_when_hosting_moderation_blocks(self, sqlite_session: Sessio with ( patch.object(ChatAppRunner, "organize_prompt_messages", return_value=([], [])), patch.object(ChatAppRunner, "moderation_for_inputs", return_value=(None, {}, "hi")), - patch.object(ChatAppRunner, "query_app_annotations_to_reply", return_value=None), + patch.object(annotation_replies, "query", return_value=None), patch.object(ChatAppRunner, "check_hosting_moderation", return_value=True), ): runner.run( @@ -429,75 +452,6 @@ def test_run_returns_when_hosting_moderation_blocks(self, sqlite_session: Sessio DummyQueueManager(), conversation, message, - sqlite_session, + app_record=sqlite_session.get(App, "app-1"), + annotations=annotation_replies, ) - - def test_run_closes_explicit_session_before_stream_consumption(self, sqlite_session: Session): - _, conversation, message = _persist_records(sqlite_session) - runner = ChatAppRunner() - app_config = SimpleNamespace( - app_id="app-1", - tenant_id="tenant-1", - app_mode=AppMode.CHAT, - prompt_template=None, - external_data_variables=[], - dataset=None, - additional_features=None, - ) - app_generate_entity = DummyGenerateEntity( - app_config=app_config, - model_conf=SimpleNamespace(provider_model_bundle=None, model="model-1", parameters={}), - inputs={}, - query="hi", - files=[], - file_upload_config=None, - conversation_id=None, - stream=True, - user_id="user-1", - invoke_from=InvokeFrom.SERVICE_API, - ) - - events = [] - queue_manager = DummyQueueManager() - model_instance = MagicMock() - - def record_commit(_session: Session) -> None: - events.append("commit") - - def invoke_stream(): - events.append("first-chunk") - yield "chunk" - - def invoke_llm(**kwargs): - events.append("invoke") - return invoke_stream() - - event.listen(sqlite_session, "after_commit", record_commit) - try: - with ( - patch.object(ChatAppRunner, "organize_prompt_messages", return_value=([], [])), - patch.object(ChatAppRunner, "moderation_for_inputs", return_value=(None, {}, "hi")), - patch.object(ChatAppRunner, "query_app_annotations_to_reply", return_value=None), - patch.object(ChatAppRunner, "check_hosting_moderation", return_value=False), - patch.object(ChatAppRunner, "recalc_llm_max_tokens"), - patch.object( - ChatAppRunner, - "_handle_invoke_result", - side_effect=lambda invoke_result, **kwargs: list(invoke_result), - ) as mock_handle, - patch("core.app.apps.chat.app_runner.ModelInstance", return_value=model_instance), - ): - model_instance.invoke_llm.side_effect = invoke_llm - runner.run(app_generate_entity, queue_manager, conversation, message, sqlite_session) - finally: - event.remove(sqlite_session, "after_commit", record_commit) - - assert events == ["commit", "commit", "invoke", "first-chunk"] - mock_handle.assert_called_once_with( - invoke_result=ANY, - queue_manager=queue_manager, - stream=True, - message_id="m1", - user_id="user-1", - tenant_id="tenant-1", - ) diff --git a/api/tests/unit_tests/core/app/apps/chat/test_base_app_runner_multimodal.py b/api/tests/unit_tests/core/app/apps/chat/test_base_app_runner_multimodal.py index c41c33487eb5fa..be63d665e9565f 100644 --- a/api/tests/unit_tests/core/app/apps/chat/test_base_app_runner_multimodal.py +++ b/api/tests/unit_tests/core/app/apps/chat/test_base_app_runner_multimodal.py @@ -7,18 +7,40 @@ from sqlalchemy import func, select from sqlalchemy.orm import Session -from core.app.apps.base_app_runner import AppRunner from core.app.entities.app_invoke_entities import InvokeFrom from graphon.file import FileTransferMethod, FileType from graphon.model_runtime.entities.message_entities import ImagePromptMessageContent from models.enums import CreatorUserRole from models.model import MessageFile from models.tools import ToolFile +from services.app.generation.adapters.base_runner import AppRunner +from tests.unit_tests.model_factories import make_app, make_conversation, make_message class TestBaseAppRunnerMultimodal: """Test that BaseAppRunner correctly handles multimodal image content.""" + @pytest.fixture(autouse=True) + def message_owner(self, sqlite_session, mock_tenant_id, mock_message_id): + sqlite_session.add_all( + [ + make_app(tenant_id=mock_tenant_id), + make_conversation(inputs={}, from_source="api"), + make_message( + message_id=mock_message_id, + inputs={}, + query="hi", + answer="", + message={}, + message_unit_price=0, + answer_unit_price=0, + currency="USD", + from_source="api", + ), + ] + ) + sqlite_session.commit() + @pytest.fixture def mock_user_id(self): """Mock user ID.""" @@ -63,6 +85,8 @@ def test_handle_multimodal_image_content_with_url( mock_queue_manager, tool_file, sqlite_session: Session, + *, + app_records, ): """Test handling image from URL.""" # Arrange @@ -73,14 +97,15 @@ def test_handle_multimodal_image_content_with_url( mime_type="image/png", ) - with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: + with patch("services.app.generation.adapters.base_runner.ToolFileManager", autospec=True) as mock_mgr_class: # Setup mock tool file manager mock_mgr = MagicMock() mock_mgr.create_file_by_url.return_value = tool_file mock_mgr_class.return_value = mock_mgr - message_file_id = AppRunner()._handle_multimodal_image_content( - session=sqlite_session, + message_file_id = AppRunner( + records=app_records, + )._handle_multimodal_image_content( content=content, message_id=mock_message_id, user_id=mock_user_id, @@ -112,6 +137,8 @@ def test_handle_multimodal_image_content_with_base64( mock_queue_manager, tool_file, sqlite_session: Session, + *, + app_records, ): """Test handling image from base64 data.""" # Arrange @@ -127,14 +154,15 @@ def test_handle_multimodal_image_content_with_base64( mime_type="image/png", ) - with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: + with patch("services.app.generation.adapters.base_runner.ToolFileManager", autospec=True) as mock_mgr_class: # Setup mock tool file manager mock_mgr = MagicMock() mock_mgr.create_file_by_raw.return_value = tool_file mock_mgr_class.return_value = mock_mgr - message_file_id = AppRunner()._handle_multimodal_image_content( - session=sqlite_session, + message_file_id = AppRunner( + records=app_records, + )._handle_multimodal_image_content( content=content, message_id=mock_message_id, user_id=mock_user_id, @@ -162,6 +190,8 @@ def test_handle_multimodal_image_content_with_base64_data_uri( mock_queue_manager, tool_file, sqlite_session: Session, + *, + app_records, ): """Test handling image from base64 data with URI prefix.""" # Arrange @@ -175,14 +205,15 @@ def test_handle_multimodal_image_content_with_base64_data_uri( mime_type="image/png", ) - with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: + with patch("services.app.generation.adapters.base_runner.ToolFileManager", autospec=True) as mock_mgr_class: # Setup mock tool file manager mock_mgr = MagicMock() mock_mgr.create_file_by_raw.return_value = tool_file mock_mgr_class.return_value = mock_mgr - message_file_id = AppRunner()._handle_multimodal_image_content( - session=sqlite_session, + message_file_id = AppRunner( + records=app_records, + )._handle_multimodal_image_content( content=content, message_id=mock_message_id, user_id=mock_user_id, @@ -196,12 +227,7 @@ def test_handle_multimodal_image_content_with_base64_data_uri( assert sqlite_session.get(MessageFile, message_file_id) is not None def test_handle_multimodal_image_content_without_url_or_base64( - self, - mock_user_id, - mock_tenant_id, - mock_message_id, - mock_queue_manager, - sqlite_session: Session, + self, mock_user_id, mock_tenant_id, mock_message_id, mock_queue_manager, sqlite_session: Session, *, app_records ): """Test handling image content without URL or base64 data.""" # Arrange @@ -212,9 +238,10 @@ def test_handle_multimodal_image_content_without_url_or_base64( mime_type="image/png", ) - with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: - result = AppRunner()._handle_multimodal_image_content( - session=sqlite_session, + with patch("services.app.generation.adapters.base_runner.ToolFileManager", autospec=True) as mock_mgr_class: + result = AppRunner( + records=app_records, + )._handle_multimodal_image_content( content=content, message_id=mock_message_id, user_id=mock_user_id, @@ -228,12 +255,7 @@ def test_handle_multimodal_image_content_without_url_or_base64( mock_queue_manager.publish.assert_not_called() def test_handle_multimodal_image_content_with_error( - self, - mock_user_id, - mock_tenant_id, - mock_message_id, - mock_queue_manager, - sqlite_session: Session, + self, mock_user_id, mock_tenant_id, mock_message_id, mock_queue_manager, sqlite_session: Session, *, app_records ): """Test handling image content when an error occurs.""" # Arrange @@ -244,13 +266,14 @@ def test_handle_multimodal_image_content_with_error( mime_type="image/png", ) - with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: + with patch("services.app.generation.adapters.base_runner.ToolFileManager", autospec=True) as mock_mgr_class: mock_mgr = MagicMock() mock_mgr.create_file_by_url.side_effect = Exception("Network error") mock_mgr_class.return_value = mock_mgr - result = AppRunner()._handle_multimodal_image_content( - session=sqlite_session, + result = AppRunner( + records=app_records, + )._handle_multimodal_image_content( content=content, message_id=mock_message_id, user_id=mock_user_id, @@ -270,6 +293,8 @@ def test_handle_multimodal_image_content_debugger_mode( mock_queue_manager, tool_file, sqlite_session: Session, + *, + app_records, ): """Test that debugger mode sets correct created_by_role.""" # Arrange @@ -281,13 +306,14 @@ def test_handle_multimodal_image_content_debugger_mode( ) mock_queue_manager.invoke_from = InvokeFrom.DEBUGGER - with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: + with patch("services.app.generation.adapters.base_runner.ToolFileManager", autospec=True) as mock_mgr_class: mock_mgr = MagicMock() mock_mgr.create_file_by_url.return_value = tool_file mock_mgr_class.return_value = mock_mgr - message_file_id = AppRunner()._handle_multimodal_image_content( - session=sqlite_session, + message_file_id = AppRunner( + records=app_records, + )._handle_multimodal_image_content( content=content, message_id=mock_message_id, user_id=mock_user_id, @@ -307,6 +333,8 @@ def test_handle_multimodal_image_content_service_api_mode( mock_queue_manager, tool_file, sqlite_session: Session, + *, + app_records, ): """Test that service API mode sets correct created_by_role.""" # Arrange @@ -318,13 +346,14 @@ def test_handle_multimodal_image_content_service_api_mode( ) mock_queue_manager.invoke_from = InvokeFrom.SERVICE_API - with patch("core.app.apps.base_app_runner.ToolFileManager", autospec=True) as mock_mgr_class: + with patch("services.app.generation.adapters.base_runner.ToolFileManager", autospec=True) as mock_mgr_class: mock_mgr = MagicMock() mock_mgr.create_file_by_url.return_value = tool_file mock_mgr_class.return_value = mock_mgr - message_file_id = AppRunner()._handle_multimodal_image_content( - session=sqlite_session, + message_file_id = AppRunner( + records=app_records, + )._handle_multimodal_image_content( content=content, message_id=mock_message_id, user_id=mock_user_id, diff --git a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter.py b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter.py index dd6cd0e919ddc4..925517bf2749fa 100644 --- a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter.py +++ b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter.py @@ -1,8 +1,8 @@ from collections.abc import Mapping, Sequence -from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter from graphon.file import FILE_MODEL_IDENTITY, File, FileTransferMethod, FileType from graphon.variables.segments import ArrayFileSegment, FileSegment +from services.workflow.execution.adapters.response_converter import WorkflowResponseConverter class TestWorkflowResponseConverterFetchFilesFromVariableValue: diff --git a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py index 8b898d8bd2f331..dc141fe45bd37a 100644 --- a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py +++ b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_human_input.py @@ -1,7 +1,6 @@ from datetime import UTC, datetime from types import SimpleNamespace -from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.queue_entities import QueueHumanInputFormFilledEvent, QueueHumanInputFormTimeoutEvent from core.workflow.system_variables import build_system_variables @@ -9,9 +8,10 @@ from graphon.runtime import GraphRuntimeState, VariablePool from graphon.variables.segments import StringSegment from models.account import Account +from services.workflow.execution.adapters.response_converter import WorkflowResponseConverter -def _build_converter(): +def _build_converter(*, workflow_contexts, tool_providers): system_variables = build_system_variables( files=[], user_id="user-1", @@ -32,14 +32,16 @@ def _build_converter(): account = Account(name="tester", email="tester@example.com") account.id = "acc-1" return WorkflowResponseConverter( + contexts=workflow_contexts, application_generate_entity=app_entity, user=account, system_variables=system_variables, + tool_providers=tool_providers, ) -def test_human_input_form_filled_stream_response_contains_rendered_content(): - converter = _build_converter() +def test_human_input_form_filled_stream_response_contains_rendered_content(*, workflow_contexts, tool_providers): + converter = _build_converter(workflow_contexts=workflow_contexts, tool_providers=tool_providers) converter.workflow_start_to_stream_response( task_id="task-1", workflow_run_id="run-1", @@ -66,8 +68,8 @@ def test_human_input_form_filled_stream_response_contains_rendered_content(): assert resp.data.action_id == "Approve" -def test_human_input_form_filled_stream_response_serializes_submitted_data(): - converter = _build_converter() +def test_human_input_form_filled_stream_response_serializes_submitted_data(*, workflow_contexts, tool_providers): + converter = _build_converter(workflow_contexts=workflow_contexts, tool_providers=tool_providers) converter.workflow_start_to_stream_response( task_id="task-1", workflow_run_id="run-1", @@ -97,8 +99,8 @@ def test_human_input_form_filled_stream_response_serializes_submitted_data(): } -def test_human_input_form_timeout_stream_response_contains_timeout_metadata(): - converter = _build_converter() +def test_human_input_form_timeout_stream_response_contains_timeout_metadata(*, workflow_contexts, tool_providers): + converter = _build_converter(workflow_contexts=workflow_contexts, tool_providers=tool_providers) converter.workflow_start_to_stream_response( task_id="task-1", workflow_run_id="run-1", diff --git a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_resumption.py b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_resumption.py index 7e397ebc73f7e5..4d5e2e4c873829 100644 --- a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_resumption.py +++ b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_resumption.py @@ -1,14 +1,14 @@ from types import SimpleNamespace -from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter from core.app.entities.app_invoke_entities import InvokeFrom from core.workflow.system_variables import build_system_variables from graphon.entities import WorkflowStartReason from graphon.runtime import GraphRuntimeState, VariablePool from models.account import Account +from services.workflow.execution.adapters.response_converter import WorkflowResponseConverter -def _build_converter() -> WorkflowResponseConverter: +def _build_converter(*, workflow_contexts, tool_providers) -> WorkflowResponseConverter: """Construct a minimal WorkflowResponseConverter for testing.""" system_variables = build_system_variables( files=[], @@ -30,14 +30,16 @@ def _build_converter() -> WorkflowResponseConverter: account = Account(name="tester", email="tester@example.com") account.id = "acc-1" return WorkflowResponseConverter( + contexts=workflow_contexts, application_generate_entity=app_entity, user=account, system_variables=system_variables, + tool_providers=tool_providers, ) -def test_workflow_start_stream_response_carries_resumption_reason(): - converter = _build_converter() +def test_workflow_start_stream_response_carries_resumption_reason(*, workflow_contexts, tool_providers): + converter = _build_converter(workflow_contexts=workflow_contexts, tool_providers=tool_providers) resp = converter.workflow_start_to_stream_response( task_id="task-1", workflow_run_id="run-1", @@ -47,8 +49,8 @@ def test_workflow_start_stream_response_carries_resumption_reason(): assert resp.data.reason is WorkflowStartReason.RESUMPTION -def test_workflow_start_stream_response_carries_initial_reason(): - converter = _build_converter() +def test_workflow_start_stream_response_carries_initial_reason(*, workflow_contexts, tool_providers): + converter = _build_converter(workflow_contexts=workflow_contexts, tool_providers=tool_providers) resp = converter.workflow_start_to_stream_response( task_id="task-1", workflow_run_id="run-1", diff --git a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_truncation.py b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_truncation.py index ecf58070655bda..d900f3b353c921 100644 --- a/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_truncation.py +++ b/api/tests/unit_tests/core/app/apps/common/test_workflow_response_converter_truncation.py @@ -12,7 +12,6 @@ import pytest from core.app.app_config.entities import WorkflowUIBasedAppConfig -from core.app.apps.common.workflow_response_converter import WorkflowResponseConverter from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity from core.app.entities.queue_entities import ( QueueEvent, @@ -30,6 +29,7 @@ from libs.datetime_utils import naive_utc_now from models import Account from models.model import AppMode +from services.workflow.execution.adapters.response_converter import WorkflowResponseConverter class TestWorkflowResponseConverter: @@ -45,7 +45,7 @@ def create_mock_generate_entity(self) -> WorkflowAppGenerateEntity: mock_entity.inputs = {} return mock_entity - def create_workflow_response_converter(self) -> WorkflowResponseConverter: + def create_workflow_response_converter(self, *, workflow_contexts, tool_providers) -> WorkflowResponseConverter: """Create a WorkflowResponseConverter for testing.""" mock_entity = self.create_mock_generate_entity() @@ -57,9 +57,11 @@ def create_workflow_response_converter(self) -> WorkflowResponseConverter: system_variables = build_system_variables(workflow_id="wf-id", workflow_execution_id="initial-run-id") return WorkflowResponseConverter( + contexts=workflow_contexts, application_generate_entity=mock_entity, user=mock_user, system_variables=system_variables, + tool_providers=tool_providers, ) def create_node_started_event(self, *, node_execution_id: str | None = None) -> QueueNodeStartedEvent: @@ -120,9 +122,11 @@ def create_node_retry_event( in_loop_id=None, ) - def test_workflow_node_finish_response_uses_truncated_process_data(self): + def test_workflow_node_finish_response_uses_truncated_process_data(self, *, workflow_contexts, tool_providers): """Test that node finish response uses get_response_process_data().""" - converter = self.create_workflow_response_converter() + converter = self.create_workflow_response_converter( + workflow_contexts=workflow_contexts, tool_providers=tool_providers + ) original_data = {"large_field": "x" * 10000, "metadata": "info"} truncated_data = {"large_field": "[TRUNCATED]", "metadata": "info"} @@ -162,9 +166,11 @@ def fake_truncate(mapping): assert response.data.process_data != original_data assert response.data.process_data_truncated is True - def test_workflow_node_finish_response_without_truncation(self): + def test_workflow_node_finish_response_without_truncation(self, *, workflow_contexts, tool_providers): """Test node finish response when no truncation is applied.""" - converter = self.create_workflow_response_converter() + converter = self.create_workflow_response_converter( + workflow_contexts=workflow_contexts, tool_providers=tool_providers + ) original_data = {"small": "data"} @@ -200,9 +206,11 @@ def fake_truncate(mapping): assert response.data.process_data == original_data assert response.data.process_data_truncated is False - def test_workflow_node_finish_response_with_none_process_data(self): + def test_workflow_node_finish_response_with_none_process_data(self, *, workflow_contexts, tool_providers): """Test node finish response when process_data is None.""" - converter = self.create_workflow_response_converter() + converter = self.create_workflow_response_converter( + workflow_contexts=workflow_contexts, tool_providers=tool_providers + ) converter.workflow_start_to_stream_response( task_id="bootstrap", @@ -237,17 +245,18 @@ def fake_truncate(mapping): assert response.data.process_data_truncated is False def test_workflow_node_finish_response_prefers_event_finished_at( - self, - monkeypatch: pytest.MonkeyPatch, + self, monkeypatch: pytest.MonkeyPatch, *, workflow_contexts, tool_providers ) -> None: """Finished timestamps should come from the event, not delayed queue processing time.""" - converter = self.create_workflow_response_converter() + converter = self.create_workflow_response_converter( + workflow_contexts=workflow_contexts, tool_providers=tool_providers + ) start_at = datetime(2024, 1, 1, 0, 0, 0, tzinfo=UTC).replace(tzinfo=None) finished_at = datetime(2024, 1, 1, 0, 0, 2, tzinfo=UTC).replace(tzinfo=None) delayed_processing_time = datetime(2024, 1, 1, 0, 0, 10, tzinfo=UTC).replace(tzinfo=None) monkeypatch.setattr( - "core.app.apps.common.workflow_response_converter.naive_utc_now", + "services.workflow.execution.adapters.response_converter.naive_utc_now", lambda: delayed_processing_time, ) converter.workflow_start_to_stream_response( @@ -280,9 +289,11 @@ def test_workflow_node_finish_response_prefers_event_finished_at( assert response.data.elapsed_time == 2.0 assert response.data.finished_at == int(finished_at.timestamp()) - def test_workflow_node_retry_response_uses_truncated_process_data(self): + def test_workflow_node_retry_response_uses_truncated_process_data(self, *, workflow_contexts, tool_providers): """Test that node retry response uses get_response_process_data().""" - converter = self.create_workflow_response_converter() + converter = self.create_workflow_response_converter( + workflow_contexts=workflow_contexts, tool_providers=tool_providers + ) original_data = {"large_field": "x" * 10000, "metadata": "info"} truncated_data = {"large_field": "[TRUNCATED]", "metadata": "info"} @@ -322,9 +333,11 @@ def fake_truncate(mapping): assert response.data.process_data != original_data assert response.data.process_data_truncated is True - def test_workflow_node_retry_response_without_truncation(self): + def test_workflow_node_retry_response_without_truncation(self, *, workflow_contexts, tool_providers): """Test node retry response when no truncation is applied.""" - converter = self.create_workflow_response_converter() + converter = self.create_workflow_response_converter( + workflow_contexts=workflow_contexts, tool_providers=tool_providers + ) original_data = {"small": "data"} @@ -359,9 +372,11 @@ def fake_truncate(mapping): assert response.data.process_data == original_data assert response.data.process_data_truncated is False - def test_iteration_and_loop_nodes_return_none(self): + def test_iteration_and_loop_nodes_return_none(self, *, workflow_contexts, tool_providers): """Test that iteration and loop nodes return None (no streaming events).""" - converter = self.create_workflow_response_converter() + converter = self.create_workflow_response_converter( + workflow_contexts=workflow_contexts, tool_providers=tool_providers + ) iteration_event = QueueNodeSucceededEvent( node_id="iteration-node", @@ -389,9 +404,11 @@ def test_iteration_and_loop_nodes_return_none(self): ) assert response is None - def test_finish_without_start_raises(self): + def test_finish_without_start_raises(self, *, workflow_contexts, tool_providers): """Ensure finish responses require a prior workflow start.""" - converter = self.create_workflow_response_converter() + converter = self.create_workflow_response_converter( + workflow_contexts=workflow_contexts, tool_providers=tool_providers + ) event = self.create_node_succeeded_event( node_execution_id=str(uuid.uuid4()), process_data={}, @@ -456,16 +473,20 @@ def create_test_system_variables(self): """Create test system variables.""" return build_system_variables() - def create_test_converter(self, invoke_from: InvokeFrom) -> WorkflowResponseConverter: + def create_test_converter( + self, invoke_from: InvokeFrom, *, workflow_contexts, tool_providers + ) -> WorkflowResponseConverter: """Create WorkflowResponseConverter with specified invoke_from.""" entity = self.create_test_app_generate_entity(invoke_from) user = self.create_test_user() system_variables = self.create_test_system_variables() converter = WorkflowResponseConverter( + contexts=workflow_contexts, application_generate_entity=entity, user=user, system_variables=system_variables, + tool_providers=tool_providers, ) # ensure `workflow_run_id` is set. converter.workflow_start_to_stream_response( @@ -512,9 +533,11 @@ def create_test_converter(self, invoke_from: InvokeFrom) -> WorkflowResponseConv ], ids=lambda x: x.name, ) - def test_truncator_selection_based_on_invoke_from(self, test_case: TestCase): + def test_truncator_selection_based_on_invoke_from(self, test_case: TestCase, *, workflow_contexts, tool_providers): """Test that the correct truncator is selected based on invoke_from.""" - converter = self.create_test_converter(test_case.invoke_from) + converter = self.create_test_converter( + test_case.invoke_from, workflow_contexts=workflow_contexts, tool_providers=tool_providers + ) # Test truncation behavior instead of checking private attribute @@ -555,9 +578,11 @@ def test_truncator_selection_based_on_invoke_from(self, test_case: TestCase): assert not response.data.process_data_truncated assert not response.data.outputs_truncated - def test_service_api_truncator_no_op_mapping(self): + def test_service_api_truncator_no_op_mapping(self, *, workflow_contexts, tool_providers): """Test that Service API truncator doesn't truncate variable mappings.""" - converter = self.create_test_converter(InvokeFrom.SERVICE_API) + converter = self.create_test_converter( + InvokeFrom.SERVICE_API, workflow_contexts=workflow_contexts, tool_providers=tool_providers + ) # Create a test event with large data large_value: dict[str, Any] = { @@ -595,9 +620,11 @@ def test_service_api_truncator_no_op_mapping(self): assert data.process_data_truncated is False assert data.outputs_truncated is False - def test_web_app_truncator_works_normally(self): + def test_web_app_truncator_works_normally(self, *, workflow_contexts, tool_providers): """Test that web app truncator still works normally.""" - converter = self.create_test_converter(InvokeFrom.WEB_APP) + converter = self.create_test_converter( + InvokeFrom.WEB_APP, workflow_contexts=workflow_contexts, tool_providers=tool_providers + ) # Create a test event with large data large_value = { @@ -695,9 +722,13 @@ def _create_event_by_type( QueueEvent.NODE_EXCEPTION, ], ) - def test_service_api_node_finish_event_no_truncation(self, event_type: QueueEvent): + def test_service_api_node_finish_event_no_truncation( + self, event_type: QueueEvent, *, workflow_contexts, tool_providers + ): """Test that Service API doesn't truncate node finish events.""" - converter = self.create_test_converter(InvokeFrom.SERVICE_API) + converter = self.create_test_converter( + InvokeFrom.SERVICE_API, workflow_contexts=workflow_contexts, tool_providers=tool_providers + ) # Create test event with large data large_inputs = {"input1": "x" * 5000, "input2": list(range(2000))} large_process_data = {"process1": "y" * 5000, "process2": {"nested": ["z"] * 2000}} @@ -723,9 +754,11 @@ def test_service_api_node_finish_event_no_truncation(self, event_type: QueueEven assert not response.data.process_data_truncated assert not response.data.outputs_truncated - def test_service_api_node_retry_event_no_truncation(self): + def test_service_api_node_retry_event_no_truncation(self, *, workflow_contexts, tool_providers): """Test that Service API doesn't truncate node retry events.""" - converter = self.create_test_converter(InvokeFrom.SERVICE_API) + converter = self.create_test_converter( + InvokeFrom.SERVICE_API, workflow_contexts=workflow_contexts, tool_providers=tool_providers + ) # Create test event with large data large_inputs = {"retry_input": "x" * 5000} @@ -784,9 +817,11 @@ def test_service_api_node_retry_event_no_truncation(self): assert not response.data.process_data_truncated assert not response.data.outputs_truncated - def test_service_api_iteration_events_no_truncation(self): + def test_service_api_iteration_events_no_truncation(self, *, workflow_contexts, tool_providers): """Test that Service API doesn't truncate iteration events.""" - converter = self.create_test_converter(InvokeFrom.SERVICE_API) + converter = self.create_test_converter( + InvokeFrom.SERVICE_API, workflow_contexts=workflow_contexts, tool_providers=tool_providers + ) # Test iteration start event large_value = {"iteration_input": ["x"] * 2000} @@ -812,9 +847,11 @@ def test_service_api_iteration_events_no_truncation(self): assert response.data.inputs == large_value assert not response.data.inputs_truncated - def test_service_api_loop_events_no_truncation(self): + def test_service_api_loop_events_no_truncation(self, *, workflow_contexts, tool_providers): """Test that Service API doesn't truncate loop events.""" - converter = self.create_test_converter(InvokeFrom.SERVICE_API) + converter = self.create_test_converter( + InvokeFrom.SERVICE_API, workflow_contexts=workflow_contexts, tool_providers=tool_providers + ) # Test loop start event large_inputs = {"loop_input": ["x"] * 2000} @@ -840,9 +877,11 @@ def test_service_api_loop_events_no_truncation(self): assert response.data.inputs == large_inputs assert not response.data.inputs_truncated - def test_web_app_node_finish_event_truncation_works(self): + def test_web_app_node_finish_event_truncation_works(self, *, workflow_contexts, tool_providers): """Test that web app still truncates node finish events.""" - converter = self.create_test_converter(InvokeFrom.WEB_APP) + converter = self.create_test_converter( + InvokeFrom.WEB_APP, workflow_contexts=workflow_contexts, tool_providers=tool_providers + ) # Create test event with large data that should be truncated large_inputs = {"input1": ["x"] * 2000} diff --git a/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py b/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py index 0af69b54b7c3fc..0492a277c24d2a 100644 --- a/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py @@ -1,29 +1,32 @@ from decimal import Decimal from types import SimpleNamespace -from unittest.mock import ANY, MagicMock +from unittest.mock import MagicMock import pytest from pytest_mock import MockerFixture -from sqlalchemy import event from sqlalchemy.orm import Session -import core.app.apps.completion.app_runner as module -from core.app.apps.completion.app_runner import CompletionAppRunner +import services.app.generation.adapters.completion_runner as module from core.app.entities.app_invoke_entities import InvokeFrom from core.credit_usage import CreditUsageAppType, CreditUsageCreatedBy from core.moderation.base import ModerationError +from extensions.application_services.retrieval import build_dataset_retrieval from graphon.model_runtime.entities.message_entities import ImagePromptMessageContent from graphon.model_runtime.entities.model_entities import ModelType from models.enums import ConversationFromSource from models.model import App, AppMode, Conversation, IconType, Message +from services.app.generation.adapters.completion_runner import CompletionAppRunner APP_ID = "00000000-0000-0000-0000-000000000001" TENANT_ID = "00000000-0000-0000-0000-000000000002" @pytest.fixture -def runner(): - return CompletionAppRunner() +def runner(*, app_records, sqlite_session_factory): + return CompletionAppRunner( + retrieval=build_dataset_retrieval(sqlite_session_factory), + records=app_records, + ) def _build_app_config(dataset=None, external_tools=None, additional_features=None): @@ -109,13 +112,6 @@ def _persist_records(session: Session) -> tuple[App, Message]: class TestCompletionAppRunner: - def test_run_app_not_found(self, runner, mocker: MockerFixture, sqlite_session: Session): - app_config = _build_app_config() - app_generate_entity = _build_generate_entity(app_config) - - with pytest.raises(ValueError): - runner.run(app_generate_entity, MagicMock(), _message(), sqlite_session) - def test_run_moderation_error_outputs_direct(self, runner, mocker: MockerFixture, sqlite_session: Session): _, message = _persist_records(sqlite_session) @@ -127,7 +123,7 @@ def test_run_moderation_error_outputs_direct(self, runner, mocker: MockerFixture runner.direct_output = MagicMock() runner._handle_invoke_result = MagicMock() - runner.run(app_generate_entity, MagicMock(), message, sqlite_session) + runner.run(app_generate_entity, MagicMock(), message, app_record=sqlite_session.get(App, APP_ID)) runner.direct_output.assert_called_once() runner._handle_invoke_result.assert_not_called() @@ -143,7 +139,7 @@ def test_run_hosting_moderation_stops(self, runner, mocker: MockerFixture, sqlit runner.check_hosting_moderation = MagicMock(return_value=True) runner._handle_invoke_result = MagicMock() - runner.run(app_generate_entity, MagicMock(), message, sqlite_session) + runner.run(app_generate_entity, MagicMock(), message, app_record=sqlite_session.get(App, APP_ID)) runner._handle_invoke_result.assert_not_called() @@ -173,7 +169,7 @@ def test_run_dataset_and_external_tools_flow(self, runner, mocker: MockerFixture dataset_retrieval = MagicMock() dataset_retrieval.retrieve.return_value = ("ctx", ["file1"]) - mocker.patch.object(module, "DatasetRetrieval", return_value=dataset_retrieval) + runner._retrieval = lambda _: dataset_retrieval model_instance = MagicMock() model_instance.invoke_llm.return_value = "invoke_result" @@ -181,69 +177,12 @@ def test_run_dataset_and_external_tools_flow(self, runner, mocker: MockerFixture model_manager.get_model_instance.return_value = model_instance mocker.patch.object(module.ModelManager, "for_tenant", return_value=model_manager) - runner.run(app_generate_entity, MagicMock(), message, sqlite_session) + runner.run(app_generate_entity, MagicMock(), message, app_record=sqlite_session.get(App, APP_ID)) dataset_retrieval.retrieve.assert_called_once() assert dataset_retrieval.retrieve.call_args.kwargs["query"] == "query_from_input" runner._handle_invoke_result.assert_called_once() - def test_run_closes_explicit_session_before_stream_consumption( - self, runner, mocker: MockerFixture, sqlite_session: Session - ): - _, message = _persist_records(sqlite_session) - app_config = _build_app_config() - app_generate_entity = _build_generate_entity(app_config) - queue_manager = MagicMock() - - events = [] - session = sqlite_session - original_close = session.close - - def record_commit(_session: Session) -> None: - events.append("commit") - - def record_close() -> None: - events.append("close") - original_close() - - runner.organize_prompt_messages = MagicMock(return_value=([], None)) - runner.moderation_for_inputs = MagicMock(return_value=(None, app_generate_entity.inputs, "query")) - runner.check_hosting_moderation = MagicMock(return_value=False) - runner.recalc_llm_max_tokens = MagicMock() - runner._handle_invoke_result = MagicMock(side_effect=lambda invoke_result, **kwargs: list(invoke_result)) - - model_instance = MagicMock() - - def invoke_stream(): - events.append("first-chunk") - yield "chunk" - - def invoke_llm(**kwargs): - events.append("invoke") - return invoke_stream() - - model_instance.invoke_llm.side_effect = invoke_llm - model_manager = MagicMock() - model_manager.get_model_instance.return_value = model_instance - mocker.patch.object(module.ModelManager, "for_tenant", return_value=model_manager) - mocker.patch.object(session, "close", side_effect=record_close) - - event.listen(session, "after_commit", record_commit) - try: - runner.run(app_generate_entity, queue_manager, message, session) - finally: - event.remove(session, "after_commit", record_commit) - - assert events == ["commit", "close", "invoke", "first-chunk"] - runner._handle_invoke_result.assert_called_once_with( - invoke_result=ANY, - queue_manager=queue_manager, - stream=True, - message_id="msg", - user_id="user", - tenant_id=TENANT_ID, - ) - @pytest.mark.parametrize("stream", [False, True]) def test_run_invokes_model_resolved_by_model_manager( self, @@ -269,7 +208,7 @@ def test_run_invokes_model_resolved_by_model_manager( model_manager.get_model_instance.return_value = model_instance model_manager_factory = mocker.patch.object(module.ModelManager, "for_tenant", return_value=model_manager) - runner.run(app_generate_entity, MagicMock(), message, sqlite_session) + runner.run(app_generate_entity, MagicMock(), message, app_record=sqlite_session.get(App, APP_ID)) model_manager_factory.assert_called_once_with(tenant_id=TENANT_ID) model_manager.get_model_instance.assert_called_once_with( @@ -300,7 +239,7 @@ def test_run_uses_low_image_detail_default(self, runner, mocker: MockerFixture, runner.moderation_for_inputs = MagicMock(return_value=(None, app_generate_entity.inputs, "query")) runner.check_hosting_moderation = MagicMock(return_value=True) - runner.run(app_generate_entity, MagicMock(), message, sqlite_session) + runner.run(app_generate_entity, MagicMock(), message, app_record=sqlite_session.get(App, APP_ID)) assert ( runner.organize_prompt_messages.call_args.kwargs["image_detail_config"] diff --git a/api/tests/unit_tests/core/app/apps/completion/test_completion_completion_app_generator.py b/api/tests/unit_tests/core/app/apps/completion/test_completion_completion_app_generator.py index 7fd35af82ebcf7..c2a91f1df0c8a5 100644 --- a/api/tests/unit_tests/core/app/apps/completion/test_completion_completion_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/completion/test_completion_completion_app_generator.py @@ -8,23 +8,29 @@ from pytest_mock import MockerFixture from sqlalchemy.orm import Session -import core.app.apps.completion.app_generator as module -from core.app.apps.completion.app_generator import CompletionAppGenerator +import services.app.generation.adapters.completion as module from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import InvokeFrom +from extensions.application_services.retrieval import build_dataset_retrieval from graphon.model_runtime.errors.invoke import InvokeAuthorizationError from models import Account from models.enums import ConversationFromSource from models.model import App, AppMode, AppModelConfig, Conversation, Message +from services.app.generation.adapters.completion import CompletionAppGenerator from services.errors.app import MoreLikeThisDisabledError +from services.errors.app_model_config import AppModelConfigBrokenError from services.errors.message import MessageNotExistsError -TABLES = (AppModelConfig, Conversation, Message) +TABLES = (App, AppModelConfig, Conversation, Message) @pytest.fixture -def generator(mocker: MockerFixture): - gen = CompletionAppGenerator() +def generator(app_records, mocker: MockerFixture, *, annotation_replies, sqlite_session_factory): + gen = CompletionAppGenerator( + retrieval=build_dataset_retrieval(sqlite_session_factory), + annotations=annotation_replies, + records=app_records, + ) mocker.patch.object(module, "copy_current_request_context", side_effect=lambda fn: fn) @@ -42,14 +48,10 @@ def generator(mocker: MockerFixture): ) gen.generate_entity = generate_entity + mocker.patch.object(gen._records, "annotation_config", return_value={"enabled": False}) return gen -@pytest.fixture(autouse=True) -def _bind_db_session(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch): - monkeypatch.setattr(module.db, "session", sqlite_session) - - def _build_app_model() -> App: return App( id="app1", @@ -80,6 +82,9 @@ def _persist_message( app_id: str = "app1", app_model_config: AppModelConfig | None = None, ) -> Message: + if session.get(App, app_id) is None: + session.add(_build_app_model()) + session.flush() conversation = Conversation( app_id=app_id, app_model_config_id=app_model_config.id if app_model_config else None, @@ -128,7 +133,7 @@ def _persist_message( app_mode=AppMode.COMPLETION, ) session.add(message) - session.flush() + session.commit() return message @@ -162,8 +167,8 @@ def test_generate_success_no_file_config(self, generator, mocker: MockerFixture, to_dict = mocker.patch.object(AppModelConfig, "to_dict", return_value={"model": {"provider": "x"}}) annotation_reply = {"enabled": False} load_annotation_reply_config = mocker.patch.object( - module, - "load_annotation_reply_config", + generator._records, + "annotation_config", return_value=annotation_reply, ) mocker.patch.object(module.FileUploadConfigManager, "convert", return_value=None) @@ -217,7 +222,7 @@ def test_generate_success_no_file_config(self, generator, mocker: MockerFixture, assert result == "converted" assert generator.generate_entity.call_args.kwargs["extras"]["trace_session_id"] == "session-1" module.file_factory.build_from_mappings.assert_not_called() - load_annotation_reply_config.assert_called_once_with(sqlite_session, "app1") + load_annotation_reply_config.assert_called_once_with(tenant_id="tenant", app_id="app1") to_dict.assert_called_once_with(annotation_reply=annotation_reply) assert get_app_config.call_args.kwargs["annotation_reply"] is annotation_reply @@ -383,7 +388,7 @@ def test_generate_more_like_this_message_config_none(self, generator, sqlite_ses app_model.app_model_config_id = current_config.id _persist_message(sqlite_session) - with pytest.raises(ValueError): + with pytest.raises(AppModelConfigBrokenError): generator.generate_more_like_this( session=sqlite_session, app_model=app_model, @@ -409,8 +414,8 @@ def test_generate_more_like_this_success(self, generator, mocker: MockerFixture, ) annotation_reply = {"enabled": False} load_annotation_reply_config = mocker.patch.object( - module, - "load_annotation_reply_config", + generator._records, + "annotation_config", return_value=annotation_reply, ) mocker.patch.object(module.FileUploadConfigManager, "convert", return_value=None) @@ -464,7 +469,7 @@ def test_generate_more_like_this_success(self, generator, mocker: MockerFixture, ) assert result == "converted" - load_annotation_reply_config.assert_called_once_with(sqlite_session, app_model.id) + load_annotation_reply_config.assert_called_once_with(tenant_id="tenant", app_id="app1") to_dict.assert_called_once_with(annotation_reply=annotation_reply) assert generator.generate_entity.call_args.kwargs["inputs"] == {"a": 1} override_dict = get_app_config.call_args.kwargs["override_config_dict"] @@ -499,7 +504,7 @@ def test_generate_worker_error_handling( flask_app.app_context.return_value = contextlib.nullcontext() message = _persist_message(sqlite_session) - mocker.patch.object(generator, "_get_message", return_value=message) + mocker.patch.object(generator._records, "load", return_value=(_build_app_model(), None, message)) runner_instance = MagicMock() runner_instance.run.side_effect = error @@ -511,6 +516,7 @@ def test_generate_worker_error_handling( application_generate_entity=MagicMock(), queue_manager=queue_manager, message_id="msg", + conversation_id="conv", ) assert queue_manager.publish_error.called is should_publish diff --git a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_config_manager.py b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_config_manager.py index a24aec4cb9240c..9f8eaa0fb72566 100644 --- a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_config_manager.py +++ b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_config_manager.py @@ -3,11 +3,11 @@ from pytest_mock import MockerFixture -import core.app.apps.pipeline.pipeline_config_manager as module -from core.app.apps.pipeline.pipeline_config_manager import PipelineConfigManager +import services.workflow.execution.adapters.pipeline.pipeline_config_manager as module from models.dataset import Pipeline from models.model import AppMode from models.workflow import Workflow, WorkflowType +from services.workflow.execution.adapters.pipeline.pipeline_config_manager import PipelineConfigManager def test_get_pipeline_config(mocker: MockerFixture): diff --git a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generate_response_converter.py b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generate_response_converter.py index cfe797aa7641c0..d978e3f9d2faf8 100644 --- a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generate_response_converter.py +++ b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_generate_response_converter.py @@ -1,6 +1,5 @@ from collections.abc import Generator -from core.app.apps.pipeline.generate_response_converter import WorkflowAppGenerateResponseConverter from core.app.entities.task_entities import ( AppStreamResponse, ErrorStreamResponse, @@ -11,6 +10,9 @@ WorkflowAppStreamResponse, ) from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus +from services.workflow.execution.adapters.pipeline.generate_response_converter import ( + WorkflowAppGenerateResponseConverter, +) def test_convert_blocking_full_and_simple_response(): diff --git a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_queue_manager.py b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_queue_manager.py index abfc76afa057a1..87824e12868002 100644 --- a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_queue_manager.py +++ b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_queue_manager.py @@ -1,10 +1,9 @@ import pytest from pytest_mock import MockerFixture -import core.app.apps.pipeline.pipeline_queue_manager as module +import services.workflow.execution.adapters.pipeline.pipeline_queue_manager as module from core.app.apps.base_app_queue_manager import PublishFrom from core.app.apps.exc import GenerateTaskStoppedError -from core.app.apps.pipeline.pipeline_queue_manager import PipelineQueueManager from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.queue_entities import ( QueueErrorEvent, @@ -15,6 +14,7 @@ QueueWorkflowSucceededEvent, ) from graphon.model_runtime.entities.llm_entities import LLMResult +from services.workflow.execution.adapters.pipeline.pipeline_queue_manager import PipelineQueueManager def test_publish_sets_stop_listen_and_raises_on_stopped(mocker: MockerFixture): diff --git a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py index e2117219e0092c..eea9e1b82a8820 100644 --- a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py +++ b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py @@ -1,7 +1,9 @@ +from services.errors.app import WorkflowNotFoundError + """Unit tests for PipelineRunner behavior. This module validates core control-flow outcomes for -``core.app.apps.pipeline.pipeline_runner``: app/workflow lookup, graph +``services.workflow.execution.adapters.pipeline.pipeline_runner``: app/workflow lookup, graph initialization guards, invoke-source to user-source resolution, and failed-run event handling. Invariants asserted here include strict graph-config validation, correct ``InvokeFrom`` to ``UserFrom`` mapping, and publishing @@ -21,8 +23,7 @@ from sqlalchemy.engine import Engine from sqlalchemy.orm import Session, sessionmaker -import core.app.apps.pipeline.pipeline_runner as module -from core.app.apps.pipeline.pipeline_runner import PipelineRunner +import services.workflow.execution.adapters.pipeline.pipeline_runner as module from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom from graphon.graph_events import GraphRunFailedEvent from models.dataset import Dataset, Document, Pipeline @@ -30,6 +31,8 @@ from models.model import EndUser from models.workflow import Workflow, WorkflowType from repositories.knowledge.document_repository import SQLAlchemyDocumentRepository +from services.workflow.execution.adapters.pipeline.pipeline_runner import PipelineRunner +from services.workflow.execution.ports import WorkflowRuntime from tests.unit_tests.model_factories import make_dataset, make_document, make_end_user, make_workflow @@ -118,7 +121,7 @@ def _build_app_generate_entity() -> SimpleNamespace: @pytest.fixture -def runner(sqlite_engine: Engine): +def runner(sqlite_engine: Engine, *, workflow_runtime: WorkflowRuntime): app_generate_entity = _build_app_generate_entity() queue_manager = MagicMock() variable_loader = MagicMock() @@ -135,19 +138,16 @@ def runner(sqlite_engine: Engine): workflow_execution_repository=workflow_execution_repository, workflow_node_execution_repository=workflow_node_execution_repository, documents=SQLAlchemyDocumentRepository(session_factory=sessionmaker(bind=sqlite_engine)), + runtime=workflow_runtime, ) -def test_get_app_id(runner): - assert runner._get_app_id() == "pipe" - - def test_get_workflow_returns_workflow(runner, sqlite_session: Session): pipeline, _, workflow = _persist_scope(sqlite_session) - result = runner.get_workflow(session=sqlite_session, pipeline=pipeline, workflow_id="wf") + result = runner._runtime.contexts.workflow(tenant_id=pipeline.tenant_id, app_id=pipeline.id, workflow_id="wf") - assert result == workflow + assert result.id == workflow.id def test_init_rag_pipeline_graph_invalid_config(mocker, runner): @@ -227,7 +227,7 @@ def record_checkout(*_args) -> None: assert checkouts == 0 -def test_run_pipeline_not_found(sqlite_engine: Engine): +def test_run_pipeline_not_found(sqlite_engine: Engine, *, workflow_runtime: WorkflowRuntime): app_generate_entity = _build_app_generate_entity() app_generate_entity.invoke_from = InvokeFrom.WEB_APP app_generate_entity.single_iteration_run = None @@ -242,9 +242,10 @@ def test_run_pipeline_not_found(sqlite_engine: Engine): workflow_execution_repository=MagicMock(), workflow_node_execution_repository=MagicMock(), documents=SQLAlchemyDocumentRepository(session_factory=sessionmaker(bind=sqlite_engine)), + runtime=workflow_runtime, ) - with pytest.raises(ValueError): + with pytest.raises(WorkflowNotFoundError): runner.run() @@ -253,7 +254,7 @@ def test_run_pipeline_from_other_tenant_is_not_found(runner: PipelineRunner, sql sqlite_session.add(pipeline) sqlite_session.commit() - with pytest.raises(ValueError, match="Pipeline not found"): + with pytest.raises(WorkflowNotFoundError, match="Pipeline not found"): runner.run() @@ -277,7 +278,7 @@ def test_run_rejects_unowned_pipeline_dataset( sqlite_session.commit() runner.get_workflow = MagicMock() - with pytest.raises(ValueError, match="Pipeline dataset not found"): + with pytest.raises((ValueError, WorkflowNotFoundError), match="Pipeline dataset not found"): runner.run() runner.get_workflow.assert_not_called() @@ -307,13 +308,15 @@ def test_run_rejects_original_document_outside_pipeline_dataset_after_async_boun _persist_scope(sqlite_session, documents=(_document(),)) runner.get_workflow = MagicMock() - with pytest.raises(ValueError, match="Pipeline original document not found"): + with pytest.raises(ValueError, match="Pipeline document not found"): runner.run() runner.get_workflow.assert_not_called() -def test_run_workflow_not_initialized(sqlite_session: Session, sqlite_engine: Engine): +def test_run_workflow_not_initialized( + sqlite_session: Session, sqlite_engine: Engine, *, workflow_runtime: WorkflowRuntime +): app_generate_entity = _build_app_generate_entity() pipeline = _pipeline() @@ -331,12 +334,19 @@ def test_run_workflow_not_initialized(sqlite_session: Session, sqlite_engine: En workflow_execution_repository=MagicMock(), workflow_node_execution_repository=MagicMock(), documents=SQLAlchemyDocumentRepository(session_factory=sessionmaker(bind=sqlite_engine)), + runtime=workflow_runtime, ) with pytest.raises(ValueError): runner.run() -def test_run_single_iteration_path(mocker: MockerFixture, sqlite_session: Session, sqlite_engine: Engine): +def test_run_single_iteration_path( + mocker: MockerFixture, + sqlite_session: Session, + sqlite_engine: Engine, + *, + workflow_runtime: WorkflowRuntime, +): app_generate_entity = _build_app_generate_entity() app_generate_entity.single_iteration_run = MagicMock() @@ -351,12 +361,13 @@ def test_run_single_iteration_path(mocker: MockerFixture, sqlite_session: Sessio workflow_execution_repository=MagicMock(), workflow_node_execution_repository=MagicMock(), documents=SQLAlchemyDocumentRepository(session_factory=sessionmaker(bind=sqlite_engine)), + runtime=workflow_runtime, ) runner._resolve_user_from = MagicMock(return_value=UserFrom.ACCOUNT) - runner._prepare_single_node_execution = MagicMock(return_value=("graph", "pool", "state")) + runner._graphs.build_single_node = MagicMock(return_value=("graph", "pool", "state")) runner._update_document_status = MagicMock() - runner._handle_event = MagicMock() + runner._events.publish = MagicMock() event = MagicMock() workflow_entry = MagicMock() @@ -368,17 +379,23 @@ def test_run_single_iteration_path(mocker: MockerFixture, sqlite_session: Sessio runner.run() - runner._prepare_single_node_execution.assert_called_once() + runner._graphs.build_single_node.assert_called_once() runner._update_document_status.assert_called_once_with( event, workspace_id=dataset.tenant_id, dataset_id=dataset.id, document_id="doc", ) - runner._handle_event.assert_called() + runner._events.publish.assert_called() -def test_run_normal_path_builds_graph(mocker: MockerFixture, sqlite_session: Session, sqlite_engine: Engine): +def test_run_normal_path_builds_graph( + mocker: MockerFixture, + sqlite_session: Session, + sqlite_engine: Engine, + *, + workflow_runtime: WorkflowRuntime, +): app_generate_entity = _build_app_generate_entity() events = [] @@ -408,12 +425,13 @@ def test_run_normal_path_builds_graph(mocker: MockerFixture, sqlite_session: Ses workflow_execution_repository=MagicMock(), workflow_node_execution_repository=MagicMock(), documents=SQLAlchemyDocumentRepository(session_factory=sessionmaker(bind=sqlite_engine)), + runtime=workflow_runtime, ) runner._resolve_user_from = MagicMock(return_value=UserFrom.ACCOUNT) runner._init_rag_pipeline_graph = MagicMock(return_value="graph") runner._update_document_status = MagicMock() - runner._handle_event = MagicMock() + runner._events.publish = MagicMock() class FakeVariablePool: def add(self, selector, value): diff --git a/api/tests/unit_tests/core/app/apps/test_advanced_chat_app_generator.py b/api/tests/unit_tests/core/app/apps/test_advanced_chat_app_generator.py index 3543022b5db853..dd3415708cc24b 100644 --- a/api/tests/unit_tests/core/app/apps/test_advanced_chat_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_advanced_chat_app_generator.py @@ -1,23 +1,24 @@ from __future__ import annotations -from types import SimpleNamespace from unittest.mock import MagicMock import pytest +from sqlalchemy import inspect from sqlalchemy.engine import Engine from sqlalchemy.orm import Session -import core.app.apps.advanced_chat.app_generator as app_generator_module from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig -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 core.ops.ops_trace_manager import TraceQueueManager from models import Account, Workflow from models.enums import ConversationFromSource from models.model import App, AppMode, Conversation +from services.app.generation.adapters import message_cycle as message_cycle_manager +from services.app.generation.adapters.message_cycle import MessageCycleManager from services.errors.conversation import ConversationNotExistsError +from services.workflow.execution.adapters.chatflow.app_generator import AdvancedChatAppGenerator +from services.workflow.execution.adapters.chatflow.record_preparation import prepare_chat_records +from services.workflow.execution.ports import WorkflowRuntime def _make_app_config() -> WorkflowUIBasedAppConfig: @@ -82,24 +83,31 @@ def _account() -> Account: return account -def test_init_generate_records_sets_conversation_metadata(sqlite_session: Session): +def test_init_generate_records_sets_conversation_metadata( + sqlite_session: Session, *, workflow_runtime: WorkflowRuntime +): app_config = _make_app_config() entity = _make_generate_entity(app_config) - generator = AdvancedChatAppGenerator() - - conversation, _ = generator._init_generate_records( - entity, - conversation=None, - session=sqlite_session, + sqlite_session.add(_app()) + sqlite_session.commit() + conversation, message = workflow_runtime.chat_records.initialize( + tenant_id=app_config.tenant_id, + app_id=app_config.app_id, + conversation_id=None, + seed=prepare_chat_records(entity, "{}"), ) - assert entity.conversation_id == conversation.id + assert message.conversation_id == conversation.id assert conversation.id is not None - assert entity.is_new_conversation is True + assert inspect(conversation).detached + assert inspect(message).detached + assert conversation.from_end_user_id == entity.user_id -def test_init_generate_records_marks_existing_conversation(sqlite_session: Session): +def test_init_generate_records_marks_existing_conversation( + sqlite_session: Session, *, workflow_runtime: WorkflowRuntime +): app_config = _make_app_config() entity = _make_generate_entity(app_config) @@ -122,26 +130,27 @@ def test_init_generate_records_marks_existing_conversation(sqlite_session: Sessi from_account_id=None, ) existing_conversation.id = "existing-conversation-id" - sqlite_session.add(existing_conversation) - sqlite_session.flush() - - generator = AdvancedChatAppGenerator() + sqlite_session.add_all([_app(), existing_conversation]) + sqlite_session.commit() - conversation, _ = generator._init_generate_records( - entity, - conversation=existing_conversation, - session=sqlite_session, + conversation, message = workflow_runtime.chat_records.initialize( + tenant_id=app_config.tenant_id, + app_id=app_config.app_id, + conversation_id=existing_conversation.id, + seed=prepare_chat_records(entity, "{}"), ) - assert entity.conversation_id == "existing-conversation-id" - assert conversation is existing_conversation - assert entity.is_new_conversation is False + assert message.conversation_id == "existing-conversation-id" + assert conversation.id == existing_conversation.id + assert conversation.name == "existing" def test_generate_falls_back_to_new_conversation_when_conversation_missing( monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine, sqlite_session: Session, + *, + workflow_runtime: WorkflowRuntime, ): app_config = _make_app_config() workflow = _workflow() @@ -152,31 +161,23 @@ def raise_conversation_not_exists(**_kwargs): raise ConversationNotExistsError() monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.ConversationService.get_conversation", + workflow_runtime.chat_records, + "conversation", raise_conversation_not_exists, ) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.FileUploadConfigManager.convert", + "services.workflow.execution.adapters.chatflow.app_generator.FileUploadConfigManager.convert", lambda *_args, **_kwargs: None, ) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.AdvancedChatAppConfigManager.get_app_config", + "services.workflow.execution.adapters.chatflow.app_generator.AdvancedChatAppConfigManager.get_app_config", lambda **_kwargs: app_config, ) - monkeypatch.setattr(app_generator_module, "db", SimpleNamespace(engine=sqlite_engine)) trace_manager = object.__new__(TraceQueueManager) monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.TraceQueueManager", + "services.workflow.execution.adapters.chatflow.app_generator.TraceQueueManager", lambda **_kwargs: trace_manager, ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.DifyCoreRepositoryFactory.create_workflow_execution_repository", - lambda **_kwargs: SimpleNamespace(), - ) - monkeypatch.setattr( - "core.app.apps.advanced_chat.app_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", - lambda **_kwargs: SimpleNamespace(), - ) captured: dict[str, object] = {} session = sqlite_session @@ -187,7 +188,7 @@ def fake_generate(self, **kwargs): monkeypatch.setattr(AdvancedChatAppGenerator, "_generate", fake_generate) - result = AdvancedChatAppGenerator().generate( + result = AdvancedChatAppGenerator(runtime=workflow_runtime).generate( app_model=app_model, workflow=workflow, user=user, @@ -195,18 +196,16 @@ def fake_generate(self, **kwargs): invoke_from=InvokeFrom.SERVICE_API, workflow_run_id="workflow-run-id", streaming=False, - session=session, ) assert result == {"status": "ok"} assert captured["conversation"] is None - assert captured["session"] is session application_generate_entity = captured["application_generate_entity"] assert isinstance(application_generate_entity, AdvancedChatAppGenerateEntity) assert application_generate_entity.conversation_id is None -def test_message_cycle_manager_uses_new_conversation_flag(monkeypatch: pytest.MonkeyPatch): +def test_message_cycle_manager_uses_new_conversation_flag(app_records, monkeypatch: pytest.MonkeyPatch): app_config = _make_app_config() entity = _make_generate_entity(app_config) entity.conversation_id = "existing-conversation-id" @@ -230,7 +229,7 @@ def fake_thread[**P](*args: P.args, **kwargs: P.kwargs): monkeypatch.setattr(message_cycle_manager, "Timer", fake_thread) - manager = MessageCycleManager(application_generate_entity=entity, task_state=MagicMock()) + manager = MessageCycleManager(records=app_records, application_generate_entity=entity, task_state=MagicMock()) thread = manager.generate_conversation_name(conversation_id="existing-conversation-id", query="hello") assert thread is captured["thread"] diff --git a/api/tests/unit_tests/core/app/apps/test_base_app_generator.py b/api/tests/unit_tests/core/app/apps/test_base_app_generator.py index 2aff04a74717ab..f0a9e40f911d21 100644 --- a/api/tests/unit_tests/core/app/apps/test_base_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_base_app_generator.py @@ -3,12 +3,16 @@ import pytest from sqlalchemy import inspect -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker -from core.app.apps.base_app_generator import BaseAppGenerator from graphon.enums import BuiltinNodeTypes, WorkflowExecutionStatus from graphon.variables.input_entities import VariableEntity, VariableEntityType from models import CreatorUserRole, Workflow, WorkflowRun, WorkflowRunTriggeredFrom, WorkflowType +from repositories.workflow.runtime_context_repository import WorkflowRuntimeContextRepository +from services.app.generation.input_adapter import AppInputAdapter +from services.app.generation.response import convert_to_event_stream +from services.errors.app import WorkflowNotFoundError +from services.workflow.execution.generation_service import consume_stream, join_worker def _workflow_run(*, graph: str | None) -> WorkflowRun: @@ -29,13 +33,12 @@ def _workflow_run(*, graph: str | None) -> WorkflowRun: def test_restore_workflow_run_graph(sqlite_session: Session): - workflow = Workflow(graph='{"nodes": [{"id": "edited"}]}') + workflow = Workflow(id="workflow-1", tenant_id="tenant-1", app_id="app-1", graph='{"nodes": [{"id": "edited"}]}') workflow_run = _workflow_run(graph='{"nodes": [{"id": "paused"}]}') sqlite_session.add(workflow_run) sqlite_session.commit() - BaseAppGenerator._restore_workflow_run_graph( - session=sqlite_session, + WorkflowRuntimeContextRepository(sessionmaker(bind=sqlite_session.get_bind())).restore_graph( workflow=workflow, workflow_run_id="run-id", ) @@ -58,16 +61,15 @@ def test_restore_workflow_run_graph_requires_persisted_snapshot( sqlite_session.add(workflow_run) sqlite_session.commit() - with pytest.raises(ValueError): - BaseAppGenerator._restore_workflow_run_graph( - session=sqlite_session, - workflow=Workflow(graph="{}"), + with pytest.raises(WorkflowNotFoundError): + WorkflowRuntimeContextRepository(sessionmaker(bind=sqlite_session.get_bind())).restore_graph( + workflow=Workflow(id="workflow-1", tenant_id="tenant-1", app_id="app-1", graph="{}"), workflow_run_id=workflow_run_id, ) def test_validate_inputs_with_zero(): - base_app_generator = BaseAppGenerator() + base_app_generator = AppInputAdapter() var = VariableEntity( variable="test_var", @@ -94,7 +96,7 @@ def test_validate_inputs_with_zero(): def test_validate_input_with_none_for_required_variable(): - base_app_generator = BaseAppGenerator() + base_app_generator = AppInputAdapter() for var_type in VariableEntityType: var = VariableEntity( @@ -116,7 +118,7 @@ def test_validate_input_with_none_for_required_variable(): def test_validate_inputs_with_default_value(): """Test that default values are used when input is None for optional variables""" - base_app_generator = BaseAppGenerator() + base_app_generator = AppInputAdapter() # Test with string default value for TEXT_INPUT var_string = VariableEntity( @@ -331,7 +333,7 @@ def test_validate_inputs_with_default_value(): def test_validate_inputs_optional_file_with_empty_string(): """Test that optional FILE variable with empty string returns None""" - base_app_generator = BaseAppGenerator() + base_app_generator = AppInputAdapter() var_file = VariableEntity( variable="test_file", @@ -350,7 +352,7 @@ def test_validate_inputs_optional_file_with_empty_string(): def test_validate_inputs_optional_file_list_with_empty_list(): """Test that optional FILE_LIST variable with empty list returns empty list (not None)""" - base_app_generator = BaseAppGenerator() + base_app_generator = AppInputAdapter() var_file_list = VariableEntity( variable="test_file_list", @@ -371,7 +373,7 @@ def test_validate_inputs_optional_file_list_with_empty_list(): def test_validate_inputs_optional_file_list_with_empty_string(): """Test that optional FILE_LIST variable with empty string returns None""" - base_app_generator = BaseAppGenerator() + base_app_generator = AppInputAdapter() var_file_list = VariableEntity( variable="test_file_list", @@ -391,7 +393,7 @@ def test_validate_inputs_optional_file_list_with_empty_string(): def test_validate_inputs_required_file_with_empty_string_fails(): """Test that required FILE variable with empty string still fails validation""" - base_app_generator = BaseAppGenerator() + base_app_generator = AppInputAdapter() var_file = VariableEntity( variable="test_file", @@ -411,7 +413,7 @@ def test_validate_inputs_required_file_with_empty_string_fails(): def test_validate_inputs_optional_file_with_empty_string_ignores_default(): """Test that optional FILE variable with empty string returns None, not the default""" - base_app_generator = BaseAppGenerator() + base_app_generator = AppInputAdapter() var_file = VariableEntity( variable="test_file", @@ -430,16 +432,16 @@ def test_validate_inputs_optional_file_with_empty_string_ignores_default(): assert result is None -class TestBaseAppGeneratorExtras: +class TestAppInputAdapterExtras: def test_wrap_stream_joins_worker_after_stream_exhaustion(self): - base_app_generator = BaseAppGenerator() + base_app_generator = AppInputAdapter() worker_thread = Mock() worker_thread.is_alive.return_value = False def response_stream(): yield {"event": "workflow_finished"} - managed_stream = base_app_generator._wrap_stream_with_worker_thread_join( + managed_stream = consume_stream( response_stream(), worker_thread, ) @@ -453,7 +455,7 @@ def response_stream(): worker_thread.join.assert_called_once_with(timeout=300) def test_wrap_stream_joins_worker_when_stream_closes(self): - base_app_generator = BaseAppGenerator() + base_app_generator = AppInputAdapter() worker_thread = Mock() worker_thread.is_alive.return_value = False @@ -461,7 +463,7 @@ def response_stream(): yield {"event": "workflow_started"} yield {"event": "workflow_finished"} - managed_stream = base_app_generator._wrap_stream_with_worker_thread_join( + managed_stream = consume_stream( response_stream(), worker_thread, ) @@ -476,15 +478,15 @@ def test_join_worker_thread_warns_when_thread_remains_alive(self, caplog: pytest worker_thread.name = "leaked-app-worker" worker_thread.is_alive.return_value = True - with caplog.at_level(logging.WARNING, logger="core.app.apps.base_app_generator"): - BaseAppGenerator._join_worker_thread(worker_thread) + with caplog.at_level(logging.WARNING, logger="services.workflow.execution.generation_service"): + join_worker(worker_thread) worker_thread.join.assert_called_once_with(timeout=300) - assert "Possible app worker thread leak" in caplog.text + assert "did not stop" in caplog.text assert "leaked-app-worker" in caplog.text def test_prepare_user_inputs_converts_files_and_lists(self, monkeypatch: pytest.MonkeyPatch): - base_app_generator = BaseAppGenerator() + base_app_generator = AppInputAdapter() variables = [ VariableEntity( @@ -514,11 +516,11 @@ def test_prepare_user_inputs_converts_files_and_lists(self, monkeypatch: pytest. ] monkeypatch.setattr( - "core.app.apps.base_app_generator.file_factory.build_from_mapping", + "services.app.generation.input_adapter.file_factory.build_from_mapping", lambda mapping, tenant_id, config, strict_type_validation=False, access_controller=None: "file-object", ) monkeypatch.setattr( - "core.app.apps.base_app_generator.file_factory.build_from_mappings", + "services.app.generation.input_adapter.file_factory.build_from_mappings", lambda mappings, tenant_id, config, access_controller=None: ["file-1", "file-2"], ) @@ -539,7 +541,7 @@ def test_prepare_user_inputs_converts_files_and_lists(self, monkeypatch: pytest. assert prepared["json"] == {"key": "value"} def test_prepare_user_inputs_rejects_invalid_dict_inputs(self): - base_app_generator = BaseAppGenerator() + base_app_generator = AppInputAdapter() variables = [ VariableEntity( variable="text", @@ -557,7 +559,7 @@ def test_prepare_user_inputs_rejects_invalid_dict_inputs(self): ) def test_prepare_user_inputs_rejects_invalid_list_inputs(self): - base_app_generator = BaseAppGenerator() + base_app_generator = AppInputAdapter() variables = [ VariableEntity( variable="text", @@ -575,39 +577,41 @@ def test_prepare_user_inputs_rejects_invalid_list_inputs(self): ) def test_convert_to_event_stream(self): - base_app_generator = BaseAppGenerator() + base_app_generator = AppInputAdapter() - assert base_app_generator.convert_to_event_stream({"ok": True}) == {"ok": True} + assert convert_to_event_stream({"ok": True}) == {"ok": True} def _gen(): yield {"delta": "hi"} yield "ping" - converted = list(base_app_generator.convert_to_event_stream(_gen())) + converted = list(convert_to_event_stream(_gen())) assert converted[0].startswith("data: ") assert "\n\n" in converted[0] assert converted[1] == "event: ping\n\n" def test_get_draft_var_saver_factory_debugger(self): + from unittest.mock import Mock + + from core.app.apps.draft_variable_saver import DraftVariableSaver, DraftVariableSaverFactory from core.app.entities.app_invoke_entities import InvokeFrom from models import Account - base_app_generator = BaseAppGenerator() + saver = Mock(spec=DraftVariableSaver) + factory = Mock(spec=DraftVariableSaverFactory, return_value=saver) + provider = Mock(return_value=factory) + generator = AppInputAdapter(draft_variable_saver=provider) account = Account(name="Tester", email="tester@example.com") account.id = "account-id" - account.tenant_id = "tenant-id" - - factory = base_app_generator._get_draft_var_saver_factory( - InvokeFrom.DEBUGGER, - account, - tenant_id="tenant-id", + bound_factory = generator._get_draft_var_saver_factory(InvokeFrom.DEBUGGER, account, tenant_id="tenant-id") + assert ( + bound_factory( + app_id="app-id", node_id="node-id", node_type=BuiltinNodeTypes.START, node_execution_id="node-exec-id" + ) + is saver ) - saver = factory( - app_id="app-id", - node_id="node-id", - node_type=BuiltinNodeTypes.START, - node_execution_id="node-exec-id", + provider.assert_called_once_with("tenant-id", account) + factory.assert_called_once_with( + app_id="app-id", node_id="node-id", node_type=BuiltinNodeTypes.START, node_execution_id="node-exec-id" ) - - assert saver is not None diff --git a/api/tests/unit_tests/core/app/apps/test_base_app_runner.py b/api/tests/unit_tests/core/app/apps/test_base_app_runner.py index b069d10352a43b..c4470be2e6a522 100644 --- a/api/tests/unit_tests/core/app/apps/test_base_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/test_base_app_runner.py @@ -3,8 +3,8 @@ import logging import pytest -from sqlalchemy import event, func, select -from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy import func, select +from sqlalchemy.orm import Session from core.app.app_config.entities import ( AdvancedChatMessageEntity, @@ -12,7 +12,6 @@ AdvancedCompletionPromptTemplateEntity, PromptTemplateEntity, ) -from core.app.apps.base_app_runner import AppRunner from core.app.apps.exc import GenerateTaskStoppedError from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager from core.app.entities.app_invoke_entities import ( @@ -25,7 +24,6 @@ QueueAgentMessageEvent, QueueLLMChunkEvent, QueueMessageEndEvent, - QueueMessageFileEvent, ) from graphon.model_runtime.entities.llm_entities import LLMResult, LLMResultChunk, LLMResultChunkDelta, LLMUsage from graphon.model_runtime.entities.message_entities import ( @@ -36,7 +34,8 @@ ) from graphon.model_runtime.entities.model_entities import AIModelEntity, ModelPropertyKey from graphon.model_runtime.errors.invoke import InvokeBadRequestError -from models.model import App, AppMode, Message, MessageFile +from models.model import App, AppMode, MessageFile +from services.app.generation.adapters.base_runner import AppRunner class _DummyParameterRule: @@ -88,8 +87,10 @@ def close(self) -> None: class TestAppRunner: - def test_recalc_llm_max_tokens_updates_parameters(self, monkeypatch: pytest.MonkeyPatch): - runner = AppRunner() + def test_recalc_llm_max_tokens_updates_parameters(self, monkeypatch: pytest.MonkeyPatch, *, app_records): + runner = AppRunner( + records=app_records, + ) model_schema = AIModelEntity.model_construct( model_properties={ModelPropertyKey.CONTEXT_SIZE: 100}, @@ -103,7 +104,7 @@ def test_recalc_llm_max_tokens_updates_parameters(self, monkeypatch: pytest.Monk ) monkeypatch.setattr( - "core.app.apps.base_app_runner.ModelInstance", + "services.app.generation.adapters.base_runner.ModelInstance", lambda provider_model_bundle, model: _TokenCountingModel(80), ) @@ -111,8 +112,12 @@ def test_recalc_llm_max_tokens_updates_parameters(self, monkeypatch: pytest.Monk assert model_config.parameters["max_tokens"] == 20 - def test_recalc_llm_max_tokens_returns_minus_one_when_no_context(self, monkeypatch: pytest.MonkeyPatch): - runner = AppRunner() + def test_recalc_llm_max_tokens_returns_minus_one_when_no_context( + self, monkeypatch: pytest.MonkeyPatch, *, app_records + ): + runner = AppRunner( + records=app_records, + ) model_schema = AIModelEntity.model_construct( model_properties={}, @@ -126,14 +131,16 @@ def test_recalc_llm_max_tokens_returns_minus_one_when_no_context(self, monkeypat ) monkeypatch.setattr( - "core.app.apps.base_app_runner.ModelInstance", + "services.app.generation.adapters.base_runner.ModelInstance", lambda provider_model_bundle, model: _TokenCountingModel(10), ) assert runner.recalc_llm_max_tokens(model_config, prompt_messages=[]) == -1 - def test_direct_output_streaming_publishes_chunks_and_end(self): - runner = AppRunner() + def test_direct_output_streaming_publishes_chunks_and_end(self, *, app_records): + runner = AppRunner( + records=app_records, + ) queue = _queue_manager() model_config = ModelConfigWithCredentialsEntity.model_construct(model="mock") app_generate_entity = EasyUIBasedAppGenerateEntity.model_construct(model_conf=model_config, stream=True) @@ -150,8 +157,10 @@ def test_direct_output_streaming_publishes_chunks_and_end(self): assert any(isinstance(event, QueueLLMChunkEvent) for event in events) assert isinstance(events[-1], QueueMessageEndEvent) - def test_handle_invoke_result_direct_publishes_end_event(self): - runner = AppRunner() + def test_handle_invoke_result_direct_publishes_end_event(self, *, app_records): + runner = AppRunner( + records=app_records, + ) queue = _queue_manager() llm_result = LLMResult( model="mock", @@ -168,8 +177,10 @@ def test_handle_invoke_result_direct_publishes_end_event(self): assert isinstance(_published_events(queue)[-1], QueueMessageEndEvent) - def test_handle_invoke_result_invalid_type_raises(self): - runner = AppRunner() + def test_handle_invoke_result_invalid_type_raises(self, *, app_records): + runner = AppRunner( + records=app_records, + ) queue = _queue_manager() with pytest.raises(NotImplementedError): @@ -179,8 +190,10 @@ def test_handle_invoke_result_invalid_type_raises(self): stream=True, ) - def test_organize_prompt_messages_simple_template(self, monkeypatch: pytest.MonkeyPatch): - runner = AppRunner() + def test_organize_prompt_messages_simple_template(self, monkeypatch: pytest.MonkeyPatch, *, app_records): + runner = AppRunner( + records=app_records, + ) model_config = ModelConfigWithCredentialsEntity.model_construct(mode="chat", stop=["STOP"]) prompt_template_entity = PromptTemplateEntity( prompt_type=PromptTemplateEntity.PromptType.SIMPLE, @@ -188,7 +201,7 @@ def test_organize_prompt_messages_simple_template(self, monkeypatch: pytest.Monk ) monkeypatch.setattr( - "core.app.apps.base_app_runner.SimplePromptTransform.get_prompt", + "services.app.generation.adapters.base_runner.SimplePromptTransform.get_prompt", lambda self, **kwargs: (["simple-message"], ["simple-stop"]), ) @@ -204,8 +217,12 @@ def test_organize_prompt_messages_simple_template(self, monkeypatch: pytest.Monk assert prompt_messages == ["simple-message"] assert stop == ["simple-stop"] - def test_organize_prompt_messages_advanced_completion_template(self, monkeypatch: pytest.MonkeyPatch): - runner = AppRunner() + def test_organize_prompt_messages_advanced_completion_template( + self, monkeypatch: pytest.MonkeyPatch, *, app_records + ): + runner = AppRunner( + records=app_records, + ) model_config = ModelConfigWithCredentialsEntity.model_construct(mode="completion", stop=[""]) captured: dict[str, object] = {} prompt_template_entity = PromptTemplateEntity( @@ -220,7 +237,9 @@ def _fake_advanced_prompt(self, **kwargs): captured.update(kwargs) return ["advanced-completion-message"] - monkeypatch.setattr("core.app.apps.base_app_runner.AdvancedPromptTransform.get_prompt", _fake_advanced_prompt) + monkeypatch.setattr( + "services.app.generation.adapters.base_runner.AdvancedPromptTransform.get_prompt", _fake_advanced_prompt + ) prompt_messages, stop = runner.organize_prompt_messages( app_record=App(mode=AppMode.CHAT.value), @@ -237,8 +256,10 @@ def _fake_advanced_prompt(self, **kwargs): assert memory_config.role_prefix.user == "U" assert memory_config.role_prefix.assistant == "A" - def test_organize_prompt_messages_advanced_chat_template(self, monkeypatch: pytest.MonkeyPatch): - runner = AppRunner() + def test_organize_prompt_messages_advanced_chat_template(self, monkeypatch: pytest.MonkeyPatch, *, app_records): + runner = AppRunner( + records=app_records, + ) model_config = ModelConfigWithCredentialsEntity.model_construct(mode="chat", stop=[""]) captured: dict[str, object] = {} prompt_template_entity = PromptTemplateEntity( @@ -255,7 +276,9 @@ def _fake_advanced_prompt(self, **kwargs): captured.update(kwargs) return ["advanced-chat-message"] - monkeypatch.setattr("core.app.apps.base_app_runner.AdvancedPromptTransform.get_prompt", _fake_advanced_prompt) + monkeypatch.setattr( + "services.app.generation.adapters.base_runner.AdvancedPromptTransform.get_prompt", _fake_advanced_prompt + ) prompt_messages, stop = runner.organize_prompt_messages( app_record=App(mode=AppMode.CHAT.value), @@ -270,8 +293,10 @@ def _fake_advanced_prompt(self, **kwargs): assert stop == [""] assert len(captured["prompt_template"]) == 2 - def test_organize_prompt_messages_advanced_missing_templates_raise(self): - runner = AppRunner() + def test_organize_prompt_messages_advanced_missing_templates_raise(self, *, app_records): + runner = AppRunner( + records=app_records, + ) with pytest.raises(InvokeBadRequestError, match="Advanced completion prompt template is required"): runner.organize_prompt_messages( @@ -291,8 +316,12 @@ def test_organize_prompt_messages_advanced_missing_templates_raise(self): files=[], ) - def test_handle_invoke_result_stream_routes_chunks_and_builds_message(self, caplog: pytest.LogCaptureFixture): - runner = AppRunner() + def test_handle_invoke_result_stream_routes_chunks_and_builds_message( + self, caplog: pytest.LogCaptureFixture, *, app_records + ): + runner = AppRunner( + records=app_records, + ) queue = _queue_manager() image_content = ImagePromptMessageContent( @@ -314,7 +343,7 @@ def _stream(): ), ) - with caplog.at_level(logging.WARNING, logger="core.app.apps.base_app_runner"): + with caplog.at_level(logging.WARNING, logger="services.app.generation.adapters.base_runner"): runner._handle_invoke_result( invoke_result=_stream(), queue_manager=queue, @@ -329,9 +358,11 @@ def _stream(): assert "Received multimodal output but missing required parameters" in caplog.messages def test_handle_invoke_result_stream_agent_mode_handles_multimodal_errors( - self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture, *, app_records ): - runner = AppRunner() + runner = AppRunner( + records=app_records, + ) queue = _queue_manager() def raise_multimodal_error(**kwargs): @@ -364,7 +395,7 @@ def _stream(): ), ) - with caplog.at_level(logging.ERROR, logger="core.app.apps.base_app_runner"): + with caplog.at_level(logging.ERROR, logger="services.app.generation.adapters.base_runner"): runner._handle_invoke_result_stream( invoke_result=_stream(), queue_manager=queue, @@ -380,67 +411,12 @@ def _stream(): assert events[-1].llm_result.usage == usage assert "Failed to handle multimodal image output" in caplog.messages - def test_handle_invoke_result_stream_commits_message_file_before_publish( - self, - monkeypatch: pytest.MonkeyPatch, - sqlite_session_factory: sessionmaker[Session], + def test_handle_invoke_result_stream_closes_generator_when_stopped( + self, monkeypatch: pytest.MonkeyPatch, *, app_records ): - runner = AppRunner() - monkeypatch.setattr( - runner, - "_handle_multimodal_image_content", - lambda **kwargs: "message-file-1", + runner = AppRunner( + records=app_records, ) - events: list[str] = [] - - def record_commit(session: Session) -> None: - events.append("commit") - - event.listen(sqlite_session_factory.class_, "after_commit", record_commit) - queue = _queue_manager() - original_publish = queue.publish - - def publish(event, pub_from): - if isinstance(event, QueueMessageFileEvent): - events.append("publish") - original_publish(event, pub_from) - - queue.publish = publish - - def stream(): - yield LLMResultChunk( - model="model", - prompt_messages=[AssistantPromptMessage(content="prompt")], - delta=LLMResultChunkDelta( - index=0, - message=AssistantPromptMessage( - content=[ - ImagePromptMessageContent( - url="https://example.com/image.png", - format="png", - mime_type="image/png", - ) - ] - ), - ), - ) - - try: - runner._handle_invoke_result_stream( - invoke_result=stream(), - queue_manager=queue, - agent=False, - message_id="message-1", - user_id="user-1", - tenant_id="tenant-1", - ) - finally: - event.remove(sqlite_session_factory.class_, "after_commit", record_commit) - - assert events == ["commit", "publish"] - - def test_handle_invoke_result_stream_closes_generator_when_stopped(self, monkeypatch: pytest.MonkeyPatch): - runner = AppRunner() chunk = LLMResultChunk( model="stream-model", prompt_messages=[AssistantPromptMessage(content="prompt")], @@ -461,11 +437,10 @@ def test_handle_invoke_result_stream_closes_generator_when_stopped(self, monkeyp assert stream.closed is True @pytest.mark.parametrize("sqlite_session", [(MessageFile,)], indirect=True) - def test_handle_multimodal_image_content_fallback_return_branch( - self, - sqlite_session: Session, - ): - runner = AppRunner() + def test_handle_multimodal_image_content_fallback_return_branch(self, sqlite_session: Session, *, app_records): + runner = AppRunner( + records=app_records, + ) class _ToggleBool: def __init__(self, values: list[bool]): @@ -487,7 +462,6 @@ def __bool__(self): queue_manager = _queue_manager() runner._handle_multimodal_image_content( - session=sqlite_session, content=content, message_id="message-id", user_id="user-id", @@ -498,14 +472,16 @@ def __bool__(self): message_file_count = sqlite_session.scalar(select(func.count()).select_from(MessageFile)) assert message_file_count == 0 - def test_check_hosting_moderation_direct_output_called(self, monkeypatch: pytest.MonkeyPatch): - runner = AppRunner() + def test_check_hosting_moderation_direct_output_called(self, monkeypatch: pytest.MonkeyPatch, *, app_records): + runner = AppRunner( + records=app_records, + ) queue = _queue_manager() app_generate_entity = EasyUIBasedAppGenerateEntity.model_construct(stream=False) direct_output_calls: list[dict[str, object]] = [] monkeypatch.setattr( - "core.app.apps.base_app_runner.HostingModerationFeature.check", + "services.app.generation.adapters.base_runner.HostingModerationFeature.check", lambda self, application_generate_entity, prompt_messages: True, ) monkeypatch.setattr(runner, "direct_output", lambda **kwargs: direct_output_calls.append(kwargs)) @@ -519,10 +495,12 @@ def test_check_hosting_moderation_direct_output_called(self, monkeypatch: pytest assert result is True assert len(direct_output_calls) == 1 - def test_fill_in_inputs_from_external_data_tools(self, monkeypatch: pytest.MonkeyPatch): - runner = AppRunner() + def test_fill_in_inputs_from_external_data_tools(self, monkeypatch: pytest.MonkeyPatch, *, app_records): + runner = AppRunner( + records=app_records, + ) monkeypatch.setattr( - "core.app.apps.base_app_runner.ExternalDataFetch.fetch", + "services.app.generation.adapters.base_runner.ExternalDataFetch.fetch", lambda self, tenant_id, app_id, external_data_tools, inputs, query: {"foo": "bar"}, ) @@ -536,10 +514,12 @@ def test_fill_in_inputs_from_external_data_tools(self, monkeypatch: pytest.Monke assert result == {"foo": "bar"} - def test_moderation_for_inputs_returns_result(self, monkeypatch: pytest.MonkeyPatch): - runner = AppRunner() + def test_moderation_for_inputs_returns_result(self, monkeypatch: pytest.MonkeyPatch, *, app_records): + runner = AppRunner( + records=app_records, + ) monkeypatch.setattr( - "core.app.apps.base_app_runner.InputModeration.check", + "services.app.generation.adapters.base_runner.InputModeration.check", lambda self, app_id, tenant_id, app_config, inputs, query, message_id, trace_manager: (True, {}, ""), ) app_generate_entity = AppGenerateEntity.model_construct(app_config=None, trace_manager=None) @@ -554,26 +534,3 @@ def test_moderation_for_inputs_returns_result(self, monkeypatch: pytest.MonkeyPa ) assert result == (True, {}, "") - - @pytest.mark.parametrize("sqlite_session", [()], indirect=True) - def test_query_app_annotations_to_reply( - self, - monkeypatch: pytest.MonkeyPatch, - sqlite_session: Session, - ): - runner = AppRunner() - monkeypatch.setattr( - "core.app.apps.base_app_runner.AnnotationReplyFeature.query", - lambda self, app_record, message, query, user_id, invoke_from, session: "reply", - ) - - response = runner.query_app_annotations_to_reply( - app_record=App(), - message=Message(), - query="hello", - user_id="user", - invoke_from=InvokeFrom.WEB_APP, - session=sqlite_session, - ) - - assert response == "reply" diff --git a/api/tests/unit_tests/core/app/apps/test_legacy_stop_graphengine_lifecycle.py b/api/tests/unit_tests/core/app/apps/test_legacy_stop_graphengine_lifecycle.py index a9aafd5949dbcf..33e8c444be17c7 100644 --- a/api/tests/unit_tests/core/app/apps/test_legacy_stop_graphengine_lifecycle.py +++ b/api/tests/unit_tests/core/app/apps/test_legacy_stop_graphengine_lifecycle.py @@ -5,10 +5,10 @@ from core.app.apps.base_app_queue_manager import PublishFrom from core.app.apps.exc import GenerateTaskStoppedError from core.app.apps.message_based_app_queue_manager import MessageBasedAppQueueManager -from core.app.apps.workflow.app_queue_manager import WorkflowAppQueueManager from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.queue_entities import QueueTextChunkEvent from models.model import AppMode +from services.workflow.execution.adapters.workflow.app_queue_manager import WorkflowAppQueueManager def _message_queue_manager(app_mode: str) -> MessageBasedAppQueueManager: diff --git a/api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py b/api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py index fdd7b53c50a547..ce427f15c615b4 100644 --- a/api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py @@ -3,7 +3,6 @@ from types import SimpleNamespace import pytest -from sqlalchemy.orm import Session from core.app.app_config.entities import ( AppAdditionalFeatures, @@ -13,10 +12,10 @@ PromptTemplateEntity, ) from core.app.apps.exc import GenerateTaskStoppedError -from core.app.apps.message_based_app_generator import MessageBasedAppGenerator from core.app.entities.app_invoke_entities import ChatAppGenerateEntity, InvokeFrom from models.account import Account from models.model import App, AppMode, Conversation, Message +from services.app.generation.message_records import MessageBasedAppGenerator from services.errors.app_model_config import AppModelConfigBrokenError from tests.unit_tests.model_factories import make_account, make_app @@ -93,16 +92,20 @@ def _make_chat_generate_entity(app_config: EasyUIBasedAppConfig) -> ChatAppGener ) -def test_init_generate_records_skips_conversation_fields_for_non_conversation_entity(sqlite_session: Session): +def test_init_generate_records_skips_conversation_fields_for_non_conversation_entity( + app_records, *, annotation_replies +): app_config = _make_app_config(AppMode.COMPLETION) entity = DummyCompletionGenerateEntity(app_config=app_config) - generator = MessageBasedAppGenerator() + generator = MessageBasedAppGenerator( + annotations=annotation_replies, + records=app_records, + ) conversation, message = generator._init_generate_records( entity, conversation=None, - session=sqlite_session, ) assert conversation.id is not None @@ -111,16 +114,18 @@ def test_init_generate_records_skips_conversation_fields_for_non_conversation_en assert hasattr(entity, "is_new_conversation") is False -def test_init_generate_records_sets_conversation_fields_for_chat_entity(sqlite_session: Session): +def test_init_generate_records_sets_conversation_fields_for_chat_entity(app_records, *, annotation_replies): app_config = _make_app_config(AppMode.CHAT) entity = _make_chat_generate_entity(app_config) - generator = MessageBasedAppGenerator() + generator = MessageBasedAppGenerator( + annotations=annotation_replies, + records=app_records, + ) conversation, _ = generator._init_generate_records( entity, conversation=None, - session=sqlite_session, ) assert entity.conversation_id == conversation.id @@ -129,8 +134,13 @@ def test_init_generate_records_sets_conversation_fields_for_chat_entity(sqlite_s class TestMessageBasedAppGeneratorExtras: - def test_handle_response_closed_file_raises_stopped(self, monkeypatch: pytest.MonkeyPatch): - generator = MessageBasedAppGenerator() + def test_handle_response_closed_file_raises_stopped( + self, app_records, monkeypatch: pytest.MonkeyPatch, *, annotation_replies + ): + generator = MessageBasedAppGenerator( + annotations=annotation_replies, + records=app_records, + ) class _Pipeline: def __init__(self, **kwargs) -> None: @@ -140,7 +150,7 @@ def process(self): raise ValueError("I/O operation on closed file.") monkeypatch.setattr( - "core.app.apps.message_based_app_generator.EasyUIBasedGenerateTaskPipeline", + "services.app.generation.message_records.EasyUIBasedGenerateTaskPipeline", _Pipeline, ) @@ -154,28 +164,38 @@ def process(self): stream=False, ) - def test_get_app_model_config_requires_valid_config(self, sqlite_session: Session): - generator = MessageBasedAppGenerator() + def test_get_app_model_config_requires_valid_config(self, app_records, *, annotation_replies): + generator = MessageBasedAppGenerator( + annotations=annotation_replies, + records=app_records, + ) app_model = _app() - session = sqlite_session with pytest.raises(AppModelConfigBrokenError): - generator._get_app_model_config(app_model, conversation=None, session=session) + generator._get_app_model_config(app_model, conversation=None) conversation = Conversation(id="conversation-id", app_id="app", app_model_config_id="missing-id") with pytest.raises(AppModelConfigBrokenError): generator._get_app_model_config( app_model=_app(), conversation=conversation, - session=session, ) - def test_get_conversation_introduction_handles_missing_inputs(self): + def test_get_conversation_introduction_handles_missing_inputs(self, app_records, *, annotation_replies): app_config = _make_app_config(AppMode.CHAT) app_config.additional_features.opening_statement = "Hello {{name}}" entity = _make_chat_generate_entity(app_config) entity.inputs = {} - generator = MessageBasedAppGenerator() + generator = MessageBasedAppGenerator( + annotations=annotation_replies, + records=app_records, + ) assert generator._get_conversation_introduction(entity) == "Hello {name}" + + +@pytest.fixture(autouse=True) +def message_app(sqlite_session): + sqlite_session.add(make_app(app_id="app-id", tenant_id="tenant-id")) + sqlite_session.commit() diff --git a/api/tests/unit_tests/core/app/apps/test_message_generator.py b/api/tests/unit_tests/core/app/apps/test_message_generator.py index a7ecf79b1e0c6c..6bdeb06a54a07b 100644 --- a/api/tests/unit_tests/core/app/apps/test_message_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_message_generator.py @@ -1,9 +1,9 @@ from unittest.mock import Mock, patch -from core.app.apps.message_generator import MessageGenerator from core.app.entities.task_entities import StreamEvent from libs.broadcast_channel.channel import SupportsPreparedSubscription from models.model import AppMode +from services.workflow.execution.adapters.response_stream import WorkflowEventStream class _PreparedSubscriber(SupportsPreparedSubscription): @@ -29,16 +29,18 @@ def subscribe(self): raise AssertionError("event retrieval must use the topic's subscriber view") -class TestMessageGenerator: +class TestWorkflowEventStream: def test_get_response_topic(self): channel = Mock() channel.topic.return_value = "topic" - with patch("core.app.apps.message_generator.get_pubsub_broadcast_channel", return_value=channel): - topic = MessageGenerator.get_response_topic(AppMode.WORKFLOW, "run-1") + with patch( + "services.workflow.execution.adapters.response_stream.get_pubsub_broadcast_channel", return_value=channel + ): + topic = WorkflowEventStream.get_response_topic(AppMode.WORKFLOW, "run-1") assert topic == "topic" - expected_key = MessageGenerator._make_channel_key(AppMode.WORKFLOW, "run-1") + expected_key = WorkflowEventStream._make_channel_key(AppMode.WORKFLOW, "run-1") channel.topic.assert_called_once_with(expected_key) def test_retrieve_events_passes_arguments(self): @@ -46,13 +48,17 @@ def test_retrieve_events_passes_arguments(self): topic.as_subscriber.return_value = topic topic.subscribe.return_value = "subscription" with ( - patch("core.app.apps.message_generator.MessageGenerator.get_response_topic", return_value=topic), patch( - "core.app.apps.message_generator.stream_topic_events", return_value=iter([{"event": "ping"}]) + "services.workflow.execution.adapters.response_stream.WorkflowEventStream.get_response_topic", + return_value=topic, + ), + patch( + "services.workflow.execution.adapters.response_stream.stream_topic_events", + return_value=iter([{"event": "ping"}]), ) as mock_stream, ): events = list( - MessageGenerator.retrieve_events( + WorkflowEventStream.retrieve_events( AppMode.WORKFLOW, "run-1", idle_timeout=1, @@ -75,10 +81,15 @@ def test_retrieve_events_passes_arguments(self): def test_retrieve_events_uses_prepared_subscription_capability(self): topic = _TopicWithSeparateSubscriberView() with ( - patch("core.app.apps.message_generator.MessageGenerator.get_response_topic", return_value=topic), - patch("core.app.apps.message_generator.stream_topic_events", return_value=iter([])) as mock_stream, + patch( + "services.workflow.execution.adapters.response_stream.WorkflowEventStream.get_response_topic", + return_value=topic, + ), + patch( + "services.workflow.execution.adapters.response_stream.stream_topic_events", return_value=iter([]) + ) as mock_stream, ): - events = MessageGenerator.retrieve_events(AppMode.WORKFLOW, "run-1") + events = WorkflowEventStream.retrieve_events(AppMode.WORKFLOW, "run-1") assert topic.subscriber.prepare_calls == 1 mock_stream.assert_called_once_with( diff --git a/api/tests/unit_tests/core/app/apps/test_pause_resume.py b/api/tests/unit_tests/core/app/apps/test_pause_resume.py index 77c56d1a7c0750..441e388cc37ecd 100644 --- a/api/tests/unit_tests/core/app/apps/test_pause_resume.py +++ b/api/tests/unit_tests/core/app/apps/test_pause_resume.py @@ -6,14 +6,9 @@ from typing import Any from pytest_mock import MockerFixture -from sqlalchemy.orm import Session import models.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 -from core.workflow import node_factory as node_factory_module -from core.workflow.node_factory import DifyNodeFactory from core.workflow.system_variables import build_system_variables from graphon.entities import WorkflowStartReason from graphon.entities.base_node_data import BaseNodeData, RetryConfig @@ -39,6 +34,11 @@ from models.enums import ConversationFromSource from models.model import App, AppMode, Conversation, Message from models.workflow import Workflow, WorkflowType +from services.workflow.execution.adapters import node_factory as node_factory_module +from services.workflow.execution.adapters.chatflow import app_generator as adv_app_gen_module +from services.workflow.execution.adapters.node_factory import DifyNodeFactory +from services.workflow.execution.adapters.workflow import app_generator as wf_app_gen_module +from services.workflow.execution.ports import WorkflowRuntime from tests.workflow_test_utils import build_test_graph_init_params if "core.ops.ops_trace_manager" not in sys.modules: @@ -271,7 +271,7 @@ def _node_successes(events: list[GraphEngineEvent]) -> list[str]: return [evt.node_id for evt in events if isinstance(evt, NodeRunSucceededEvent)] -def test_workflow_app_pause_resume_matches_baseline(mocker: MockerFixture): +def test_workflow_app_pause_resume_matches_baseline(mocker: MockerFixture, *, workflow_runtime: WorkflowRuntime): _patch_tool_node(mocker) baseline_state = _build_runtime_state("baseline") @@ -288,7 +288,7 @@ def test_workflow_app_pause_resume_matches_baseline(mocker: MockerFixture): resumed_state = GraphRuntimeState.from_snapshot(snapshot) - generator = wf_app_gen_module.WorkflowAppGenerator() + generator = wf_app_gen_module.WorkflowAppGenerator(runtime=workflow_runtime) def _fake_generate(**kwargs): state: GraphRuntimeState = kwargs["graph_runtime_state"] @@ -315,7 +315,7 @@ def _fake_generate(**kwargs): assert resumed_state.outputs == baseline_outputs -def test_advanced_chat_pause_resume_matches_baseline(mocker: MockerFixture, unbound_session: Session): +def test_advanced_chat_pause_resume_matches_baseline(mocker: MockerFixture, *, workflow_runtime: WorkflowRuntime): _patch_tool_node(mocker) baseline_state = _build_runtime_state("adv-baseline") @@ -332,7 +332,7 @@ def test_advanced_chat_pause_resume_matches_baseline(mocker: MockerFixture, unbo resumed_state = GraphRuntimeState.from_snapshot(snapshot) - generator = adv_app_gen_module.AdvancedChatAppGenerator() + generator = adv_app_gen_module.AdvancedChatAppGenerator(runtime=workflow_runtime) def _fake_generate(**kwargs): state: GraphRuntimeState = kwargs["graph_runtime_state"] @@ -347,7 +347,6 @@ def _fake_generate(**kwargs): user=_make_account(), conversation=_make_conversation(), message=_make_message(), - session=unbound_session, application_generate_entity=SimpleNamespace( stream=False, invoke_from=InvokeFrom.SERVICE_API, diff --git a/api/tests/unit_tests/core/app/apps/test_streaming_utils.py b/api/tests/unit_tests/core/app/apps/test_streaming_utils.py index 66afebb3e60f4a..3fb75e6e5b1378 100644 --- a/api/tests/unit_tests/core/app/apps/test_streaming_utils.py +++ b/api/tests/unit_tests/core/app/apps/test_streaming_utils.py @@ -5,11 +5,11 @@ import pytest -from core.app.apps.message_based_app_generator import MessageBasedAppGenerator from core.app.apps.streaming_utils import _normalize_terminal_events, stream_topic_events from core.app.entities.task_entities import StreamEvent from libs.broadcast_channel.channel import SupportsPreparedSubscription from models.model import AppMode +from services.workflow.execution.adapters.response_stream import WorkflowEventStream class FakeSubscription: @@ -107,14 +107,14 @@ def test_retrieve_events_falls_back_to_subscribe_and_invokes_hook_after_entry(mo def fake_get_response_topic(cls, app_mode, workflow_run_id): return topic - monkeypatch.setattr(MessageBasedAppGenerator, "get_response_topic", classmethod(fake_get_response_topic)) + monkeypatch.setattr(WorkflowEventStream, "get_response_topic", classmethod(fake_get_response_topic)) def on_subscribe() -> None: assert topic.subscribed is True event = {"event": StreamEvent.WORKFLOW_FINISHED.value} topic.publish(json.dumps(event).encode()) - generator = MessageBasedAppGenerator.retrieve_events( + generator = WorkflowEventStream.retrieve_events( AppMode.WORKFLOW, "workflow-run-id", idle_timeout=0.5, @@ -137,9 +137,9 @@ def test_retrieve_events_prepares_capable_topic_before_generator_iteration(monke def fake_get_response_topic(cls, app_mode, workflow_run_id): return topic - monkeypatch.setattr(MessageBasedAppGenerator, "get_response_topic", classmethod(fake_get_response_topic)) + monkeypatch.setattr(WorkflowEventStream, "get_response_topic", classmethod(fake_get_response_topic)) - generator = MessageBasedAppGenerator.retrieve_events( + generator = WorkflowEventStream.retrieve_events( AppMode.WORKFLOW, "workflow-run-id", idle_timeout=0.5, @@ -161,10 +161,10 @@ def test_retrieve_events_propagates_preparation_error_before_generator_iteration def fake_get_response_topic(cls, app_mode, workflow_run_id): return topic - monkeypatch.setattr(MessageBasedAppGenerator, "get_response_topic", classmethod(fake_get_response_topic)) + monkeypatch.setattr(WorkflowEventStream, "get_response_topic", classmethod(fake_get_response_topic)) with pytest.raises(RuntimeError, match="prepare failed"): - MessageBasedAppGenerator.retrieve_events( + WorkflowEventStream.retrieve_events( AppMode.WORKFLOW, "workflow-run-id", idle_timeout=0.5, diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py index 90016228bdd9b4..1359fe4e43806d 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py @@ -1,17 +1,16 @@ +from dataclasses import replace + """SQLite-backed tests for workflow app generation and worker reload behavior.""" import contextlib -from types import SimpleNamespace from unittest.mock import MagicMock import pytest from flask import Flask from sqlalchemy.orm import Session, sessionmaker -import core.app.apps.workflow.app_generator as app_generator_module from core.app.app_config.entities import WorkflowUIBasedAppConfig from core.app.apps.draft_variable_saver import DraftVariableSaverFactory -from core.app.apps.workflow.app_generator import WorkflowAppGenerator from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity from core.app.layers.pause_state_persist_layer import PauseStateLayerConfig, PauseStatePersistenceLayer from core.ops.ops_trace_manager import TraceQueueManager @@ -20,8 +19,9 @@ from graphon.runtime import GraphRuntimeState, VariablePool from models.enums import CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom from models.model import App, AppMode, EndUser -from models.snippet import CustomizedSnippet from models.workflow import Workflow, WorkflowKind, WorkflowNodeExecutionTriggeredFrom, WorkflowRun +from services.workflow.execution.adapters.workflow.app_generator import WorkflowAppGenerator +from services.workflow.execution.ports import WorkflowRuntime from tests.unit_tests.model_factories import make_workflow @@ -124,88 +124,31 @@ def _repositories( ) -def test_ensure_snippet_start_node_in_worker_returns_standard_workflow_without_lookup( - sqlite_session: Session, -) -> None: - workflow = _workflow() - sqlite_session.add(workflow) - sqlite_session.commit() - - result = WorkflowAppGenerator._ensure_snippet_start_node_in_worker(session=sqlite_session, workflow=workflow) - - assert result is workflow - - -def test_ensure_snippet_start_node_in_worker_returns_snippet_workflow_when_snippet_missing( - sqlite_session: Session, -) -> None: - workflow = _workflow(app_id="snippet-1", tenant_id="tenant-1", kind=WorkflowKind.SNIPPET) - other_tenant_snippet = CustomizedSnippet( - id="snippet-1", - tenant_id="tenant-2", - name="Other tenant snippet", - description="", - type="node", - ) - sqlite_session.add_all([workflow, other_tenant_snippet]) - sqlite_session.commit() - - result = WorkflowAppGenerator._ensure_snippet_start_node_in_worker(session=sqlite_session, workflow=workflow) - - assert result is workflow - - -def test_ensure_snippet_start_node_in_worker_applies_snippet_start_injection( - monkeypatch: pytest.MonkeyPatch, sqlite_session: Session -) -> None: - workflow = _workflow(app_id="snippet-1", tenant_id="tenant-1", kind=WorkflowKind.SNIPPET) - snippet = CustomizedSnippet( - id="snippet-1", - tenant_id="tenant-1", - name="Matching snippet", - description="", - type="node", - ) - sqlite_session.add_all([workflow, snippet]) - sqlite_session.commit() - ensure_start_node = MagicMock(return_value=workflow) - monkeypatch.setattr( - "services.snippet_generate_service.SnippetGenerateService.ensure_start_node_for_worker", - ensure_start_node, - ) - - result = WorkflowAppGenerator._ensure_snippet_start_node_in_worker(session=sqlite_session, workflow=workflow) - - assert result is workflow - ensure_start_node.assert_called_once_with(workflow, snippet) - - def test_generate_includes_parent_trace_context_in_extras( - monkeypatch: pytest.MonkeyPatch, sqlite_session: Session + monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, *, workflow_runtime: WorkflowRuntime ) -> None: - generator = WorkflowAppGenerator() + generator = WorkflowAppGenerator(runtime=workflow_runtime) app, workflow, end_user = _persist_generator_rows(sqlite_session) monkeypatch.setattr( - "core.app.apps.workflow.app_generator.WorkflowAppGenerator._bind_file_access_scope", + "services.workflow.execution.adapters.workflow.app_generator.WorkflowAppGenerator._bind_file_access_scope", lambda *args, **kwargs: contextlib.nullcontext(), ) monkeypatch.setattr( - "core.app.apps.workflow.app_generator.WorkflowAppConfigManager.get_app_config", + "services.workflow.execution.adapters.workflow.app_generator.WorkflowAppConfigManager.get_app_config", lambda *args, **kwargs: _app_config(app, workflow), ) monkeypatch.setattr( - "core.app.apps.workflow.app_generator.file_factory.build_from_mappings", lambda *args, **kwargs: [] + "services.workflow.execution.adapters.workflow.app_generator.file_factory.build_from_mappings", + lambda *args, **kwargs: [], ) monkeypatch.setattr( - "core.app.apps.workflow.app_generator.TraceQueueManager", + "services.workflow.execution.adapters.workflow.app_generator.TraceQueueManager", MagicMock(return_value=MagicMock(spec=TraceQueueManager)), ) repository_tenant_ids: dict[str, str] = {} - workflow_execution_factory = app_generator_module.DifyCoreRepositoryFactory.create_workflow_execution_repository - workflow_node_execution_factory = ( - app_generator_module.DifyCoreRepositoryFactory.create_workflow_node_execution_repository - ) + workflow_execution_factory = workflow_runtime.execution_writer + workflow_node_execution_factory = workflow_runtime.node_writer def create_workflow_execution_repository(**kwargs): repository_tenant_ids["workflow"] = kwargs["tenant_id"] @@ -215,15 +158,11 @@ def create_workflow_node_execution_repository(**kwargs): repository_tenant_ids["node"] = kwargs["tenant_id"] return workflow_node_execution_factory(**kwargs) - monkeypatch.setattr( - "core.app.apps.workflow.app_generator.DifyCoreRepositoryFactory.create_workflow_execution_repository", - create_workflow_execution_repository, - ) - monkeypatch.setattr( - "core.app.apps.workflow.app_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", - create_workflow_node_execution_repository, + generator._runtime = replace( + workflow_runtime, + execution_writer=create_workflow_execution_repository, + node_writer=create_workflow_node_execution_repository, ) - monkeypatch.setattr("core.app.apps.workflow.app_generator.db", SimpleNamespace(engine=sqlite_session.get_bind())) monkeypatch.setattr(generator, "_prepare_user_inputs", lambda *, user_inputs, **kwargs: user_inputs) captured = {} @@ -272,8 +211,10 @@ def test_resume_delegates_to_generate( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_session_factory: sessionmaker[Session], + *, + workflow_runtime: WorkflowRuntime, ) -> None: - generator = WorkflowAppGenerator() + generator = WorkflowAppGenerator(runtime=workflow_runtime) app, workflow, end_user = _persist_generator_rows(sqlite_session) mock_generate = MagicMock(return_value="ok") monkeypatch.setattr(generator, "_generate", mock_generate) @@ -319,24 +260,26 @@ def test_generate_appends_pause_layer_and_forwards_state( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_session_factory: sessionmaker[Session], + *, + workflow_runtime: WorkflowRuntime, ) -> None: - generator = WorkflowAppGenerator() + generator = WorkflowAppGenerator(runtime=workflow_runtime) app, workflow, end_user = _persist_generator_rows(sqlite_session) queue_manager = MagicMock() monkeypatch.setattr( - "core.app.apps.workflow.app_generator.WorkflowAppQueueManager", + "services.workflow.execution.adapters.workflow.app_generator.WorkflowAppQueueManager", MagicMock(return_value=queue_manager), ) monkeypatch.setattr( - "core.app.apps.workflow.app_generator.WorkflowAppGenerateResponseConverter.convert", + "services.workflow.execution.adapters.workflow.app_generator.WorkflowAppGenerateResponseConverter.convert", MagicMock(return_value="converted"), ) monkeypatch.setattr(WorkflowAppGenerator, "_handle_response", MagicMock(return_value="response")) draft_factory_tenant_ids: list[str] = [] - get_draft_var_saver_factory = WorkflowAppGenerator._get_draft_var_saver_factory + get_draft_var_saver_factory = generator._get_draft_var_saver_factory def get_recording_draft_var_saver_factory( invoke_from: InvokeFrom, account: EndUser, *, tenant_id: str @@ -351,7 +294,6 @@ def get_recording_draft_var_saver_factory( ) db_session = sqlite_session_factory() - monkeypatch.setattr(app_generator_module.db, "session", db_session) worker_kwargs: dict[str, object] = {} @@ -370,7 +312,7 @@ def join(self, timeout): def is_alive(self): return False - monkeypatch.setattr("core.app.apps.workflow.app_generator.threading.Thread", DummyThread) + monkeypatch.setattr("services.workflow.execution.adapters.workflow.app_generator.threading.Thread", DummyThread) application_generate_entity = _generate_entity(app, workflow, end_user) graph_runtime_state = _runtime_state() @@ -411,8 +353,10 @@ def test_resume_path_runs_worker_with_runtime_state( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, sqlite_session_factory: sessionmaker[Session], + *, + workflow_runtime: WorkflowRuntime, ) -> None: - generator = WorkflowAppGenerator() + generator = WorkflowAppGenerator(runtime=workflow_runtime) app, workflow, end_user = _persist_generator_rows(sqlite_session) workflow_run = WorkflowRun( id="run", @@ -434,18 +378,17 @@ def test_resume_path_runs_worker_with_runtime_state( queue_manager = MagicMock() monkeypatch.setattr( - "core.app.apps.workflow.app_generator.WorkflowAppQueueManager", + "services.workflow.execution.adapters.workflow.app_generator.WorkflowAppQueueManager", MagicMock(return_value=queue_manager), ) monkeypatch.setattr(generator, "_handle_response", MagicMock(return_value="raw-response")) monkeypatch.setattr( - "core.app.apps.workflow.app_generator.WorkflowAppGenerateResponseConverter.convert", + "services.workflow.execution.adapters.workflow.app_generator.WorkflowAppGenerateResponseConverter.convert", MagicMock(side_effect=lambda response, invoke_from: response), ) db_session = sqlite_session_factory() - monkeypatch.setattr(app_generator_module.db, "session", db_session) runner_instance = MagicMock() @@ -457,7 +400,7 @@ def runner_ctor(**kwargs): return runner_instance monkeypatch.setattr( - "core.app.apps.workflow.app_generator.WorkflowAppRunner", + "services.workflow.execution.adapters.workflow.app_generator.WorkflowAppRunner", MagicMock(side_effect=runner_ctor), ) @@ -477,7 +420,7 @@ def join(self, timeout): def is_alive(self): return False - monkeypatch.setattr("core.app.apps.workflow.app_generator.threading.Thread", ImmediateThread) + monkeypatch.setattr("services.workflow.execution.adapters.workflow.app_generator.threading.Thread", ImmediateThread) pause_config = PauseStateLayerConfig( session_factory=sqlite_session_factory, diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py index c2ecd33d32335f..e62fd02c1d8e3f 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_core.py @@ -8,7 +8,6 @@ import pytest from pydantic import ValidationError -from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner from core.app.entities.app_invoke_entities import DIFY_RUN_CONTEXT_KEY, InvokeFrom, UserFrom from core.app.entities.queue_entities import ( QueueAgentLogEvent, @@ -52,6 +51,8 @@ from graphon.variables.segments import StringSegment from graphon.variables.variables import StringVariable from models.workflow import Workflow +from services.workflow.execution.adapters.events import WorkflowEventPublisher, enqueue_human_input_notifications +from services.workflow.execution.adapters.graph import WorkflowGraphBuilder from tests.unit_tests.model_factories import make_workflow @@ -64,16 +65,16 @@ def _workflow(graph: dict[str, object] | None = None) -> Workflow: ) -class TestWorkflowBasedAppRunner: +class TestWorkflowGraphBuilder: def test_resolve_user_from(self): - runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app") + runner = WorkflowGraphBuilder(app_id="app") - assert runner._resolve_user_from(InvokeFrom.EXPLORE) == UserFrom.ACCOUNT - assert runner._resolve_user_from(InvokeFrom.DEBUGGER) == UserFrom.ACCOUNT - assert runner._resolve_user_from(InvokeFrom.WEB_APP) == UserFrom.END_USER + assert runner.resolve_user_from(InvokeFrom.EXPLORE) == UserFrom.ACCOUNT + assert runner.resolve_user_from(InvokeFrom.DEBUGGER) == UserFrom.ACCOUNT + assert runner.resolve_user_from(InvokeFrom.WEB_APP) == UserFrom.END_USER def test_init_graph_validates_graph_structure(self): - runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app") + runner = WorkflowGraphBuilder(app_id="app") runtime_state = GraphRuntimeState( variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), @@ -81,7 +82,7 @@ def test_init_graph_validates_graph_structure(self): ) with pytest.raises(ValidationError, match="nodes"): - runner._init_graph( + runner.build( graph_config={}, graph_runtime_state=runtime_state, user_from=UserFrom.ACCOUNT, @@ -89,7 +90,7 @@ def test_init_graph_validates_graph_structure(self): ) with pytest.raises(ValidationError, match="nodes"): - runner._init_graph( + runner.build( graph_config={"nodes": {}, "edges": []}, graph_runtime_state=runtime_state, user_from=UserFrom.ACCOUNT, @@ -97,7 +98,7 @@ def test_init_graph_validates_graph_structure(self): ) with pytest.raises(ValidationError, match="edges"): - runner._init_graph( + runner.build( graph_config={"nodes": [], "edges": {}}, graph_runtime_state=runtime_state, user_from=UserFrom.ACCOUNT, @@ -105,7 +106,7 @@ def test_init_graph_validates_graph_structure(self): ) def test_init_graph_includes_trace_session_id_in_run_context(self, monkeypatch: pytest.MonkeyPatch): - runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app") + runner = WorkflowGraphBuilder(app_id="app") runtime_state = GraphRuntimeState( variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, @@ -117,12 +118,14 @@ def fake_from_graph_init_context(**kwargs): return SimpleNamespace() monkeypatch.setattr( - "core.app.apps.workflow_app_runner.DifyNodeFactory.from_graph_init_context", + "services.workflow.execution.adapters.graph.DifyNodeFactory.from_graph_init_context", fake_from_graph_init_context, ) - monkeypatch.setattr("core.app.apps.workflow_app_runner.Graph.init", lambda **_kwargs: SimpleNamespace()) + monkeypatch.setattr( + "services.workflow.execution.adapters.graph.Graph.init", lambda **_kwargs: SimpleNamespace() + ) - runner._init_graph( + runner.build( graph_config={"nodes": [], "edges": []}, graph_runtime_state=runtime_state, user_from=UserFrom.ACCOUNT, @@ -134,15 +137,15 @@ def fake_from_graph_init_context(**kwargs): assert captured["run_context"][DIFY_RUN_CONTEXT_KEY].trace_session_id == "session-1" def test_prepare_single_node_execution_requires_run(self): - runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app") + runner = WorkflowGraphBuilder(app_id="app") workflow = _workflow() with pytest.raises(ValueError, match="Neither single_iteration_run nor single_loop_run"): - runner._prepare_single_node_execution(workflow, None, None, user_id="00000000-0000-0000-0000-000000000001") + runner.build_single_node(workflow, None, None, user_id="00000000-0000-0000-0000-000000000001") def test_get_graph_and_variable_pool_for_single_node_run(self, monkeypatch: pytest.MonkeyPatch): - runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app") + runner = WorkflowGraphBuilder(app_id="app") graph_runtime_state = GraphRuntimeState( variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, @@ -156,7 +159,7 @@ def test_get_graph_and_variable_pool_for_single_node_run(self, monkeypatch: pyte workflow.id = "workflow" monkeypatch.setattr( - "core.app.apps.workflow_app_runner.Graph.init", + "services.workflow.execution.adapters.graph.Graph.init", lambda **kwargs: SimpleNamespace(), ) @@ -165,7 +168,7 @@ class _NodeCls: def extract_variable_selector_to_variable_mapping(graph_config, config): return {} - from core.app.apps import workflow_app_runner + from services.workflow.execution.adapters import graph as workflow_app_runner monkeypatch.setattr( workflow_app_runner, @@ -173,11 +176,11 @@ def extract_variable_selector_to_variable_mapping(graph_config, config): lambda **_kwargs: _NodeCls, ) monkeypatch.setattr( - "core.app.apps.workflow_app_runner.load_into_variable_pool", + "services.workflow.execution.adapters.graph.load_into_variable_pool", lambda **kwargs: None, ) monkeypatch.setattr( - "core.app.apps.workflow_app_runner.WorkflowEntry.mapping_user_inputs_to_variable_pool", + "services.workflow.execution.adapters.graph.WorkflowEntry.mapping_user_inputs_to_variable_pool", lambda **kwargs: None, ) @@ -197,7 +200,7 @@ def extract_variable_selector_to_variable_mapping(graph_config, config): def test_get_graph_and_variable_pool_for_single_node_run_does_not_mutate_inputs( self, monkeypatch: pytest.MonkeyPatch ): - runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app") + runner = WorkflowGraphBuilder(app_id="app") graph_runtime_state = GraphRuntimeState( variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, @@ -228,16 +231,16 @@ class _NodeCls: def extract_variable_selector_to_variable_mapping(graph_config, config): return {} - from core.app.apps import workflow_app_runner + from services.workflow.execution.adapters import graph as workflow_app_runner - monkeypatch.setattr("core.app.apps.workflow_app_runner.Graph.init", fake_graph_init) + monkeypatch.setattr("services.workflow.execution.adapters.graph.Graph.init", fake_graph_init) monkeypatch.setattr(workflow_app_runner, "resolve_workflow_node_class", lambda **_kwargs: _NodeCls) monkeypatch.setattr( - "core.app.apps.workflow_app_runner.load_into_variable_pool", + "services.workflow.execution.adapters.graph.load_into_variable_pool", lambda **kwargs: kwargs["user_inputs"].update({"from_loader": "local-only"}), ) monkeypatch.setattr( - "core.app.apps.workflow_app_runner.WorkflowEntry.mapping_user_inputs_to_variable_pool", + "services.workflow.execution.adapters.graph.WorkflowEntry.mapping_user_inputs_to_variable_pool", lambda **kwargs: kwargs["user_inputs"].update({"from_mapping": "local-only"}), ) @@ -262,7 +265,7 @@ def extract_variable_selector_to_variable_mapping(graph_config, config): def test_get_graph_and_variable_pool_for_single_node_run_includes_trace_session_id( self, monkeypatch: pytest.MonkeyPatch ): - runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app") + runner = WorkflowGraphBuilder(app_id="app") graph_runtime_state = GraphRuntimeState( variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, @@ -284,17 +287,17 @@ class _NodeCls: def extract_variable_selector_to_variable_mapping(graph_config, config): return {} - from core.app.apps import workflow_app_runner + from services.workflow.execution.adapters import graph as workflow_app_runner monkeypatch.setattr( - "core.app.apps.workflow_app_runner.DifyNodeFactory.from_graph_init_context", + "services.workflow.execution.adapters.graph.DifyNodeFactory.from_graph_init_context", fake_from_graph_init_context, ) - monkeypatch.setattr("core.app.apps.workflow_app_runner.Graph.init", lambda **kwargs: SimpleNamespace()) + monkeypatch.setattr("services.workflow.execution.adapters.graph.Graph.init", lambda **kwargs: SimpleNamespace()) monkeypatch.setattr(workflow_app_runner, "resolve_workflow_node_class", lambda **_kwargs: _NodeCls) - monkeypatch.setattr("core.app.apps.workflow_app_runner.load_into_variable_pool", lambda **kwargs: None) + monkeypatch.setattr("services.workflow.execution.adapters.graph.load_into_variable_pool", lambda **kwargs: None) monkeypatch.setattr( - "core.app.apps.workflow_app_runner.WorkflowEntry.mapping_user_inputs_to_variable_pool", + "services.workflow.execution.adapters.graph.WorkflowEntry.mapping_user_inputs_to_variable_pool", lambda **kwargs: None, ) @@ -327,8 +330,7 @@ def test_get_graph_and_variable_pool_preloads_constructor_variables_before_graph else [] ) ) - runner = WorkflowBasedAppRunner( - queue_manager=SimpleNamespace(), + runner = WorkflowGraphBuilder( variable_loader=variable_loader, app_id="app", ) @@ -367,19 +369,19 @@ def _graph_init(**kwargs): return SimpleNamespace() monkeypatch.setattr( - "core.app.apps.workflow_app_runner.Graph.init", + "services.workflow.execution.adapters.graph.Graph.init", _graph_init, ) monkeypatch.setattr( - "core.app.apps.workflow_app_runner.resolve_workflow_node_class", + "services.workflow.execution.adapters.graph.resolve_workflow_node_class", lambda **_kwargs: _LoopNodeCls, ) monkeypatch.setattr( - "core.app.apps.workflow_app_runner.load_into_variable_pool", + "services.workflow.execution.adapters.graph.load_into_variable_pool", lambda **kwargs: None, ) monkeypatch.setattr( - "core.app.apps.workflow_app_runner.WorkflowEntry.mapping_user_inputs_to_variable_pool", + "services.workflow.execution.adapters.graph.WorkflowEntry.mapping_user_inputs_to_variable_pool", lambda **kwargs: None, ) @@ -402,7 +404,11 @@ class _QueueManager: def publish(self, event, publish_from): published.append((event, publish_from)) - runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app") + runner = WorkflowEventPublisher( + queue_manager=_QueueManager(), + resolve_pause=lambda **kwargs: [], + notify_pause=enqueue_human_input_notifications, + ) graph_runtime_state = GraphRuntimeState( variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, @@ -416,11 +422,12 @@ def apply_async(self, *, kwargs, queue): emails.append({"kwargs": kwargs, "queue": queue}) monkeypatch.setattr( - "core.app.apps.workflow_app_runner.dispatch_human_input_email_task", + "services.workflow.execution.adapters.events.dispatch_human_input_email_task", _Dispatch(), ) monkeypatch.setattr( - "core.app.apps.workflow_app_runner.enrich_graph_pause_reasons", + runner, + "_resolve_pause", lambda **_: [ HumanInputRequired( form_id="form", @@ -437,9 +444,9 @@ def apply_async(self, *, kwargs, queue): node_title="Node", ) - runner._handle_event(workflow_entry, GraphRunStartedEvent()) - runner._handle_event(workflow_entry, GraphRunSucceededEvent(outputs={"ok": True})) - runner._handle_event(workflow_entry, GraphRunPausedEvent(reasons=[reason], outputs={})) + runner.publish(workflow_entry, GraphRunStartedEvent()) + runner.publish(workflow_entry, GraphRunSucceededEvent(outputs={"ok": True})) + runner.publish(workflow_entry, GraphRunPausedEvent(reasons=[reason], outputs={})) assert any(isinstance(event, QueueWorkflowStartedEvent) for event, _ in published) assert any(isinstance(event, QueueWorkflowSucceededEvent) for event, _ in published) @@ -455,10 +462,14 @@ def publish(self, event, publish_from): del publish_from published.append(event) - runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app") + runner = WorkflowEventPublisher( + queue_manager=_QueueManager(), + resolve_pause=lambda **kwargs: [], + notify_pause=enqueue_human_input_notifications, + ) workflow_entry = SimpleNamespace() - runner._handle_event(workflow_entry, GraphRunAbortedEvent(reason="User requested stop", outputs={})) + runner.publish(workflow_entry, GraphRunAbortedEvent(reason="User requested stop", outputs={})) event = published[-1] assert isinstance(event, QueueStopEvent) @@ -471,14 +482,18 @@ class _QueueManager: def publish(self, event, publish_from): published.append(event) - runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app") + runner = WorkflowEventPublisher( + queue_manager=_QueueManager(), + resolve_pause=lambda **kwargs: [], + notify_pause=enqueue_human_input_notifications, + ) graph_runtime_state = GraphRuntimeState( variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) workflow_entry = SimpleNamespace(graph_engine=SimpleNamespace(graph_runtime_state=graph_runtime_state)) - runner._handle_event( + runner.publish( workflow_entry, NodeRunStartedEvent( id="exec", @@ -488,7 +503,7 @@ def publish(self, event, publish_from): start_at=datetime.now(UTC), ), ) - runner._handle_event( + runner.publish( workflow_entry, NodeRunStreamChunkEvent( id="exec", @@ -499,7 +514,7 @@ def publish(self, event, publish_from): is_final=False, ), ) - runner._handle_event( + runner.publish( workflow_entry, NodeRunReasoningChunkEvent( id="exec", @@ -510,7 +525,7 @@ def publish(self, event, publish_from): is_final=False, ), ) - runner._handle_event( + runner.publish( workflow_entry, NodeRunAgentLogEvent( id="exec", @@ -526,7 +541,7 @@ def publish(self, event, publish_from): metadata={}, ), ) - runner._handle_event( + runner.publish( workflow_entry, NodeRunIterationSucceededEvent( id="exec", @@ -540,7 +555,7 @@ def publish(self, event, publish_from): steps=1, ), ) - runner._handle_event( + runner.publish( workflow_entry, NodeRunLoopFailedEvent( id="exec", @@ -569,7 +584,11 @@ class _QueueManager: def publish(self, event, publish_from): published.append(event) - runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app") + runner = WorkflowEventPublisher( + queue_manager=_QueueManager(), + resolve_pause=lambda **kwargs: [], + notify_pause=enqueue_human_input_notifications, + ) graph_runtime_state = GraphRuntimeState( variable_pool=VariablePool.from_bootstrap( system_variables=default_system_variables(), @@ -580,7 +599,7 @@ def publish(self, event, publish_from): ) workflow_entry = SimpleNamespace(graph_engine=SimpleNamespace(graph_runtime_state=graph_runtime_state)) - runner._handle_event( + runner.publish( workflow_entry, NodeRunHumanInputFormFilledEvent( id="exec", @@ -605,14 +624,18 @@ class _QueueManager: def publish(self, event, publish_from): published.append(event) - runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app") + runner = WorkflowEventPublisher( + queue_manager=_QueueManager(), + resolve_pause=lambda **kwargs: [], + notify_pause=enqueue_human_input_notifications, + ) graph_runtime_state = GraphRuntimeState( variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) workflow_entry = SimpleNamespace(graph_engine=SimpleNamespace(graph_runtime_state=graph_runtime_state)) - runner._handle_event( + runner.publish( workflow_entry, NodeRunSucceededEvent( id="exec", @@ -639,14 +662,18 @@ class _QueueManager: def publish(self, event, publish_from): published.append(event) - runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app") + runner = WorkflowEventPublisher( + queue_manager=_QueueManager(), + resolve_pause=lambda **kwargs: [], + notify_pause=enqueue_human_input_notifications, + ) graph_runtime_state = GraphRuntimeState( variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, ) workflow_entry = SimpleNamespace(graph_engine=SimpleNamespace(graph_runtime_state=graph_runtime_state)) - runner._handle_event( + runner.publish( workflow_entry, NodeRunSucceededEvent( id="exec", @@ -722,7 +749,11 @@ class _QueueManager: def publish(self, event, publish_from): published.append(event) - runner = WorkflowBasedAppRunner(queue_manager=_QueueManager(), app_id="app") + runner = WorkflowEventPublisher( + queue_manager=_QueueManager(), + resolve_pause=lambda **kwargs: [], + notify_pause=enqueue_human_input_notifications, + ) graph_runtime_state = GraphRuntimeState( variable_pool=VariablePool.from_bootstrap(system_variables=default_system_variables()), start_at=0.0, @@ -740,7 +771,7 @@ def publish(self, event, publish_from): }, ) - runner._handle_event(workflow_entry, event_factory(result, started_at, finished_at)) + runner.publish(workflow_entry, event_factory(result, started_at, finished_at)) queue_event = published[-1] assert isinstance(queue_event, queue_event_cls) diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_notifications.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_notifications.py index f7f9bdb4ba2dc1..2b45c20aa34d90 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_notifications.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_notifications.py @@ -2,11 +2,11 @@ import pytest -from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner from core.app.entities.queue_entities import QueueWorkflowPausedEvent from core.workflow.nodes.human_input.pause_reason import HumanInputRequired from graphon.entities.pause_reason import HitlRequired from graphon.graph_events import GraphRunPausedEvent +from services.workflow.execution.adapters.events import WorkflowEventPublisher, enqueue_human_input_notifications class _DummyQueueManager: @@ -33,7 +33,9 @@ def __init__(self): def test_handle_pause_event_enqueues_email_task(monkeypatch: pytest.MonkeyPatch): queue_manager = _DummyQueueManager() - runner = WorkflowBasedAppRunner(queue_manager=queue_manager, app_id="app-id") + runner = WorkflowEventPublisher( + queue_manager=queue_manager, resolve_pause=lambda **_: [], notify_pause=enqueue_human_input_notifications + ) workflow_entry = _DummyWorkflowEntry() graph_reason = HitlRequired(session_id="form-123", node_id="node-1", node_title="Review") @@ -49,12 +51,13 @@ def test_handle_pause_event_enqueues_email_task(monkeypatch: pytest.MonkeyPatch) node_title="Review", ) monkeypatch.setattr( - "core.app.apps.workflow_app_runner.enrich_graph_pause_reasons", + runner, + "_resolve_pause", lambda **_: [enriched_reason], ) - monkeypatch.setattr("core.app.apps.workflow_app_runner.dispatch_human_input_email_task", email_task) + monkeypatch.setattr("services.workflow.execution.adapters.events.dispatch_human_input_email_task", email_task) - runner._handle_event(workflow_entry, event) + runner.publish(workflow_entry, event) email_task.apply_async.assert_called_once() kwargs = email_task.apply_async.call_args.kwargs["kwargs"] diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py index baa831ee689fe7..e265969940dbed 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner_single_node.py @@ -8,14 +8,15 @@ from pydantic import ValidationError from core.app.apps.base_app_queue_manager import AppQueueManager -from core.app.apps.workflow.app_runner import WorkflowAppRunner -from core.app.apps.workflow_app_runner import WorkflowBasedAppRunner from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity from core.credit_usage import CreditUsageAppType from core.workflow.system_variables import default_system_variables from graphon.runtime import GraphRuntimeState, VariablePool from models.model import AppMode from models.workflow import Workflow, WorkflowKind +from services.workflow.execution.adapters.graph import WorkflowGraphBuilder +from services.workflow.execution.adapters.workflow.app_runner import WorkflowAppRunner +from services.workflow.execution.ports import WorkflowRuntime def _make_graph_state(): @@ -36,8 +37,7 @@ def _make_graph_state(): ], ) def test_run_uses_single_node_execution_branch( - single_iteration_run: Any, - single_loop_run: Any, + single_iteration_run: Any, single_loop_run: Any, *, workflow_runtime: WorkflowRuntime ) -> None: app_config = MagicMock() app_config.app_id = "app" @@ -77,6 +77,7 @@ def test_run_uses_single_node_execution_branch( system_user_id="system-user", workflow_execution_repository=MagicMock(), workflow_node_execution_repository=MagicMock(), + runtime=workflow_runtime, ) graph, variable_pool, graph_runtime_state = _make_graph_state() @@ -86,19 +87,21 @@ def test_run_uses_single_node_execution_branch( mock_workflow_entry.run.return_value = iter([]) with ( - patch("core.app.apps.workflow.app_runner.RedisChannel"), - patch("core.app.apps.workflow.app_runner.redis_client"), - patch("core.app.apps.workflow.app_runner.WorkflowEntry", return_value=mock_workflow_entry) as entry_class, + patch("services.workflow.execution.adapters.workflow.app_runner.RedisChannel"), + patch("services.workflow.execution.adapters.workflow.app_runner.redis_client"), + patch( + "services.workflow.execution.adapters.workflow.app_runner.WorkflowEntry", return_value=mock_workflow_entry + ) as entry_class, patch.object( - runner, - "_prepare_single_node_execution", + runner._graphs, + "build_single_node", return_value=( graph, variable_pool, graph_runtime_state, ), ) as prepare_single, - patch.object(runner, "_init_graph") as init_graph, + patch.object(runner._graphs, "build") as init_graph, ): runner.run() @@ -119,8 +122,7 @@ def test_run_uses_single_node_execution_branch( def test_single_node_run_validates_target_node_config() -> None: - runner = WorkflowBasedAppRunner( - queue_manager=MagicMock(spec=AppQueueManager), + runner = WorkflowGraphBuilder( variable_loader=MagicMock(), app_id="app", ) @@ -155,7 +157,7 @@ def test_single_node_run_validates_target_node_config() -> None: ) -def test_run_adds_inputs_with_snippet_compatible_start_aliases() -> None: +def test_run_adds_inputs_with_snippet_compatible_start_aliases(*, workflow_runtime: WorkflowRuntime) -> None: app_config = MagicMock() app_config.app_id = "app" app_config.tenant_id = "tenant" @@ -194,6 +196,7 @@ def test_run_adds_inputs_with_snippet_compatible_start_aliases() -> None: system_user_id="system-user", workflow_execution_repository=MagicMock(), workflow_node_execution_repository=MagicMock(), + runtime=workflow_runtime, ) mock_workflow_entry = MagicMock() @@ -202,18 +205,24 @@ def test_run_adds_inputs_with_snippet_compatible_start_aliases() -> None: mock_workflow_entry.run.return_value = iter([]) with ( - patch("core.app.apps.workflow.app_runner.RedisChannel"), - patch("core.app.apps.workflow.app_runner.redis_client"), - patch("core.app.apps.workflow.app_runner.WorkflowEntry", return_value=mock_workflow_entry), - patch("core.app.apps.workflow.app_runner.build_system_variables", return_value={}), - patch("core.app.apps.workflow.app_runner.build_bootstrap_variables", return_value=[]), - patch("core.app.apps.workflow.app_runner.add_variables_to_pool"), - patch("core.app.apps.workflow.app_runner.get_default_root_node_id", return_value="root-node"), + patch("services.workflow.execution.adapters.workflow.app_runner.RedisChannel"), + patch("services.workflow.execution.adapters.workflow.app_runner.redis_client"), + patch( + "services.workflow.execution.adapters.workflow.app_runner.WorkflowEntry", return_value=mock_workflow_entry + ), + patch("services.workflow.execution.adapters.workflow.app_runner.build_system_variables", return_value={}), + patch("services.workflow.execution.adapters.workflow.app_runner.build_bootstrap_variables", return_value=[]), + patch("services.workflow.execution.adapters.workflow.app_runner.add_variables_to_pool"), + patch( + "services.workflow.execution.adapters.workflow.app_runner.get_default_root_node_id", + return_value="root-node", + ), patch( - "core.app.apps.workflow.app_runner.get_compatible_start_aliases", return_value=("legacy-start",) + "services.workflow.execution.adapters.workflow.app_runner.get_compatible_start_aliases", + return_value=("legacy-start",), ) as aliases, - patch("core.app.apps.workflow.app_runner.add_node_inputs_to_pool") as add_inputs, - patch.object(runner, "_init_graph", return_value=MagicMock()), + patch("services.workflow.execution.adapters.workflow.app_runner.add_node_inputs_to_pool") as add_inputs, + patch.object(runner._graphs, "build", return_value=MagicMock()), ): runner.run() diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py b/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py index d156a0d5af6a80..47efb3ddf57843 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_pause_events.py @@ -1,14 +1,9 @@ -import json from datetime import UTC, datetime from types import SimpleNamespace -from unittest.mock import MagicMock import pytest from sqlalchemy.orm import Session -from core.app.apps.common import workflow_response_converter -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 @@ -21,30 +16,20 @@ from graphon.runtime import GraphRuntimeState, VariablePool from models.account import Account from models.human_input import HumanInputForm, HumanInputFormRecipient -from models.human_input_entities import ParagraphInputConfig, SelectInputConfig, StringListSource, UserActionConfig -from models.workflow import Workflow, WorkflowType - - -class _RecordingWorkflowAppRunner(WorkflowAppRunner): - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.published_events = [] - - def _publish_event(self, event): - self.published_events.append(event) +from models.human_input_entities import ( + ParagraphInputConfig, + SelectInputConfig, + StringListSource, + UserActionConfig, +) +from services.workflow.execution.adapters.events import WorkflowEventPublisher +from services.workflow.execution.adapters.response_converter import WorkflowResponseConverter class _FakeRuntimeState: variable_pool = object() -@pytest.fixture -def sqlite_pause_session(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> Session: - """Bind pause-response queries to the shared SQLite session's database.""" - monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=sqlite_session.get_bind())) - return sqlite_session - - def _persist_human_input_form( session: Session, *, @@ -78,47 +63,14 @@ def _persist_human_input_form( return expiration_time -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 = Workflow.new( - tenant_id="tenant-id", - app_id="app-id", - type=WorkflowType.WORKFLOW, - version=Workflow.VERSION_DRAFT, - graph=json.dumps({}), - features="{}", - created_by="account-id", - environment_variables=[], - conversation_variables=[], - rag_pipeline_variables=[], - ) - workflow.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(): + published = [] + class Queue: + def publish(self, event, _pub_from): + published.append(event) -def test_graph_run_paused_event_emits_queue_pause_event(monkeypatch: pytest.MonkeyPatch): - runner = _build_runner() + runner = WorkflowEventPublisher(Queue(), resolve_pause=lambda **_: [enriched_reason], notify_pause=lambda _: None) graph_reason = HitlRequired( session_id="form-1", node_id="node-human", @@ -137,23 +89,17 @@ def test_graph_run_paused_event_emits_queue_pause_event(monkeypatch: pytest.Monk node_id="node-human", node_title="Human Step", ) - 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", MagicMock()) - - runner._handle_event(workflow_entry, event) + runner.publish(workflow_entry, event) - assert len(runner.published_events) == 1 - queue_event = runner.published_events[0] + assert len(published) == 1 + queue_event = published[0] assert isinstance(queue_event, QueueWorkflowPausedEvent) assert queue_event.reasons == [enriched_reason] assert queue_event.outputs == {"foo": "bar"} assert queue_event.paused_nodes == ["node-human"] -def _build_converter(*, invoke_from: InvokeFrom = InvokeFrom.SERVICE_API): +def _build_converter(*, invoke_from: InvokeFrom = InvokeFrom.SERVICE_API, workflow_contexts, tool_providers): application_generate_entity = SimpleNamespace( inputs={}, files=[], @@ -169,9 +115,11 @@ def _build_converter(*, invoke_from: InvokeFrom = InvokeFrom.SERVICE_API): user = Account(name="Tester", email="tester@example.com") user.id = "account-id" return WorkflowResponseConverter( + contexts=workflow_contexts, application_generate_entity=application_generate_entity, user=user, system_variables=system_variables, + tool_providers=tool_providers, ) @@ -180,8 +128,8 @@ def _build_converter(*, invoke_from: InvokeFrom = InvokeFrom.SERVICE_API): [(HumanInputForm, HumanInputFormRecipient)], indirect=True, ) -def test_queue_workflow_paused_event_to_stream_responses(sqlite_pause_session: Session): - converter = _build_converter() +def test_queue_workflow_paused_event_to_stream_responses(sqlite_session: Session, *, workflow_contexts, tool_providers): + converter = _build_converter(workflow_contexts=workflow_contexts, tool_providers=tool_providers) converter.workflow_start_to_stream_response( task_id="task", workflow_run_id="run-id", @@ -190,7 +138,7 @@ def test_queue_workflow_paused_event_to_stream_responses(sqlite_pause_session: S ) expiration_time = _persist_human_input_form( - sqlite_pause_session, + sqlite_session, recipients=[ (RecipientType.CONSOLE, "console-token"), (RecipientType.BACKSTAGE, "backstage-token"), @@ -239,11 +187,12 @@ def test_queue_workflow_paused_event_to_stream_responses(sqlite_pause_session: S def _build_paused_human_input_response( - session: Session, - recipients: list[tuple[RecipientType, str]], + session: Session, recipients: list[tuple[RecipientType, str]], *, workflow_contexts, tool_providers ): """Drive the live OPENAPI pause path with persisted forms and recipients.""" - converter = _build_converter(invoke_from=InvokeFrom.OPENAPI) + converter = _build_converter( + workflow_contexts=workflow_contexts, invoke_from=InvokeFrom.OPENAPI, tool_providers=tool_providers + ) converter.workflow_start_to_stream_response( task_id="task", workflow_run_id="run-id", @@ -282,13 +231,17 @@ def _build_paused_human_input_response( [(HumanInputForm, HumanInputFormRecipient)], indirect=True, ) -def test_openapi_pause_without_web_app_recipient_emits_approval_channels(sqlite_pause_session: Session): +def test_openapi_pause_without_web_app_recipient_emits_approval_channels( + sqlite_session: Session, *, workflow_contexts, tool_providers +): responses = _build_paused_human_input_response( - sqlite_pause_session, + sqlite_session, + workflow_contexts=workflow_contexts, recipients=[ (RecipientType.EMAIL_MEMBER, "email-token"), (RecipientType.BACKSTAGE, "backstage-token"), ], + tool_providers=tool_providers, ) hi_resp = responses[0] @@ -304,13 +257,17 @@ def test_openapi_pause_without_web_app_recipient_emits_approval_channels(sqlite_ [(HumanInputForm, HumanInputFormRecipient)], indirect=True, ) -def test_openapi_pause_with_web_app_recipient_sets_token_and_channels(sqlite_pause_session: Session): +def test_openapi_pause_with_web_app_recipient_sets_token_and_channels( + sqlite_session: Session, *, workflow_contexts, tool_providers +): responses = _build_paused_human_input_response( - sqlite_pause_session, + sqlite_session, + workflow_contexts=workflow_contexts, recipients=[ (RecipientType.STANDALONE_WEB_APP, "web-app-token"), (RecipientType.BACKSTAGE, "backstage-token"), ], + tool_providers=tool_providers, ) hi_resp = responses[0] @@ -326,8 +283,10 @@ def test_openapi_pause_with_web_app_recipient_sets_token_and_channels(sqlite_pau [(HumanInputForm, HumanInputFormRecipient)], indirect=True, ) -def test_queue_workflow_paused_event_resolves_variable_select_options(sqlite_pause_session: Session): - converter = _build_converter() +def test_queue_workflow_paused_event_resolves_variable_select_options( + sqlite_session: Session, *, workflow_contexts, tool_providers +): + converter = _build_converter(workflow_contexts=workflow_contexts, tool_providers=tool_providers) converter.workflow_start_to_stream_response( task_id="task", workflow_run_id="run-id", @@ -335,7 +294,7 @@ def test_queue_workflow_paused_event_resolves_variable_select_options(sqlite_pau reason=WorkflowStartReason.INITIAL, ) - _persist_human_input_form(sqlite_pause_session) + _persist_human_input_form(sqlite_session) reason = HumanInputRequired( form_id="form-1", diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_active_workflow_tasks.py b/api/tests/unit_tests/core/app/apps/workflow/test_active_workflow_tasks.py index 769dd0c092d8ac..4cf61322733b8d 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_active_workflow_tasks.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_active_workflow_tasks.py @@ -3,12 +3,12 @@ import pytest -from core.app.apps.base_app_generator import BaseAppGenerator -from core.app.apps.workflow.active_workflow_tasks import ( +from services.workflow.execution.adapters.workflow.active_workflow_tasks import ( active_workflow_task, get_active_workflow_task_count, reset_active_workflow_tasks, ) +from services.workflow.execution.generation_service import consume_stream @pytest.fixture(autouse=True) @@ -54,7 +54,7 @@ def response_stream() -> Generator[dict[str, str], None, None]: worker_thread.start() assert worker_started.wait(timeout=2) - managed_stream = BaseAppGenerator._wrap_stream_with_worker_thread_join(response_stream(), worker_thread) + managed_stream = consume_stream(response_stream(), worker_thread) assert next(managed_stream) == {"event": "workflow_finished"} def finish_stream() -> None: diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py b/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py index c3d2d2c9077dcb..b31a6c257ed149 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py @@ -11,14 +11,17 @@ from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig from core.app.apps.exc import GenerateTaskStoppedError -from core.app.apps.workflow import app_generator as app_generator_module -from core.app.apps.workflow.app_generator import WorkflowAppGenerator from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity from core.ops.ops_trace_manager import TraceQueueManager +from graphon.variable_loader import VariableLoader from models.enums import EndUserType from models.model import App, AppMode, EndUser from models.snippet import CustomizedSnippet from models.workflow import Workflow, WorkflowKind, WorkflowType +from repositories.agent.runtime_repository import WorkflowAgentBindingResolver +from services.workflow.execution.adapters.workflow import app_generator as app_generator_module +from services.workflow.execution.adapters.workflow.app_generator import WorkflowAppGenerator +from services.workflow.execution.ports import WorkflowRuntime TENANT_ID = "00000000-0000-0000-0000-000000000001" OTHER_TENANT_ID = "00000000-0000-0000-0000-000000000002" @@ -28,21 +31,6 @@ CREATOR_ID = "00000000-0000-0000-0000-000000000006" -@pytest.fixture -def sqlite_generator_session( - monkeypatch: pytest.MonkeyPatch, - sqlite_engine, - sqlite_session: Session, -) -> Session: - """Expose the shared SQLite engine and real Session through the Flask extension boundary.""" - monkeypatch.setattr( - app_generator_module, - "db", - SimpleNamespace(engine=sqlite_engine, session=sqlite_session), - ) - return sqlite_session - - def _app() -> App: return App( id=APP_ID, @@ -149,11 +137,14 @@ def _persist_snippet( class TestWorkflowAppGeneratorValidation: - @pytest.mark.usefixtures("sqlite_generator_session") def test_generate_stream_joins_worker_after_response_exhaustion( - self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session + self, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + *, + workflow_runtime: WorkflowRuntime, ): - generator = WorkflowAppGenerator() + generator = WorkflowAppGenerator(runtime=workflow_runtime) app = _persist_app(sqlite_session) workflow = _persist_workflow(sqlite_session) user = _persist_end_user(sqlite_session) @@ -183,19 +174,24 @@ def response_stream(): monkeypatch.setattr(generator, "_bind_file_access_scope", lambda **kwargs: contextlib.nullcontext()) monkeypatch.setattr( - "core.app.apps.workflow.app_generator.WorkflowAppQueueManager", + "services.workflow.execution.adapters.workflow.app_generator.WorkflowAppQueueManager", lambda **kwargs: SimpleNamespace(**kwargs), ) monkeypatch.setattr( - "core.app.apps.workflow.app_generator.current_app", + "services.workflow.execution.adapters.workflow.app_generator.current_app", SimpleNamespace(_get_current_object=lambda: SimpleNamespace(name="flask")), ) - monkeypatch.setattr("core.app.apps.workflow.app_generator.contextvars.copy_context", lambda: "ctx") - monkeypatch.setattr("core.app.apps.workflow.app_generator.threading.Thread", lambda **kwargs: worker_thread) + monkeypatch.setattr( + "services.workflow.execution.adapters.workflow.app_generator.contextvars.copy_context", lambda: "ctx" + ) + monkeypatch.setattr( + "services.workflow.execution.adapters.workflow.app_generator.threading.Thread", + lambda **kwargs: worker_thread, + ) monkeypatch.setattr(generator, "_get_draft_var_saver_factory", lambda *args, **kwargs: "draft-factory") monkeypatch.setattr(generator, "_handle_response", lambda **kwargs: response_stream()) monkeypatch.setattr( - "core.app.apps.workflow.app_generator.WorkflowAppGenerateResponseConverter.convert", + "services.workflow.execution.adapters.workflow.app_generator.WorkflowAppGenerateResponseConverter.convert", lambda response, invoke_from: response, ) @@ -215,57 +211,8 @@ def response_stream(): assert list(managed_stream) == [{"event": "workflow_finished"}] worker_thread.join.assert_called_once_with(timeout=300) - def test_ensure_snippet_start_node_returns_original_for_non_snippet_workflow( - self, - unbound_session: Session, - ): - workflow = _workflow() - - result = WorkflowAppGenerator._ensure_snippet_start_node_in_worker( - session=unbound_session, - workflow=workflow, - ) - - assert result is workflow - - def test_ensure_snippet_start_node_returns_original_when_snippet_is_from_another_tenant( - self, - sqlite_session: Session, - ): - workflow = _persist_workflow(sqlite_session, kind=WorkflowKind.SNIPPET) - _persist_snippet(sqlite_session, snippet_id=APP_ID, tenant_id=OTHER_TENANT_ID) - - result = WorkflowAppGenerator._ensure_snippet_start_node_in_worker( - session=sqlite_session, - workflow=workflow, - ) - - assert result is workflow - - def test_ensure_snippet_start_node_delegates_when_snippet_exists( - self, - monkeypatch: pytest.MonkeyPatch, - sqlite_session: Session, - ): - workflow = _persist_workflow(sqlite_session, kind=WorkflowKind.SNIPPET) - snippet = _persist_snippet(sqlite_session, snippet_id=APP_ID) - injected_workflow = _workflow(workflow_id="workflow-injected") - ensure_start_node = Mock(return_value=injected_workflow) - monkeypatch.setattr( - "services.snippet_generate_service.SnippetGenerateService.ensure_start_node_for_worker", - ensure_start_node, - ) - - result = WorkflowAppGenerator._ensure_snippet_start_node_in_worker( - session=sqlite_session, - workflow=workflow, - ) - - assert result is injected_workflow - ensure_start_node.assert_called_once_with(workflow, snippet) - - def test_single_iteration_generate_validates_args(self, sqlite_session: Session): - generator = WorkflowAppGenerator() + def test_single_iteration_generate_validates_args(self, *, workflow_runtime: WorkflowRuntime): + generator = WorkflowAppGenerator(runtime=workflow_runtime) with pytest.raises(ValueError, match="node_id is required"): generator.single_iteration_generate( @@ -275,7 +222,6 @@ def test_single_iteration_generate_validates_args(self, sqlite_session: Session) user=_end_user(), args={"inputs": {}}, streaming=False, - session=sqlite_session, ) with pytest.raises(ValueError, match="inputs is required"): @@ -286,11 +232,10 @@ def test_single_iteration_generate_validates_args(self, sqlite_session: Session) user=_end_user(), args={}, streaming=False, - session=sqlite_session, ) - def test_single_loop_generate_validates_args(self, sqlite_session: Session): - generator = WorkflowAppGenerator() + def test_single_loop_generate_validates_args(self, *, workflow_runtime: WorkflowRuntime): + generator = WorkflowAppGenerator(runtime=workflow_runtime) with pytest.raises(ValueError, match="node_id is required"): generator.single_loop_generate( @@ -298,18 +243,20 @@ def test_single_loop_generate_validates_args(self, sqlite_session: Session): workflow=_workflow(), node_id="", user=_end_user(), - args=SimpleNamespace(inputs={}), + args={"inputs": {}}, streaming=False, - session=sqlite_session, ) - @pytest.mark.usefixtures("sqlite_generator_session") def test_single_iteration_generate_includes_trace_session_id_in_extras( self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, + *, + workflow_runtime: WorkflowRuntime, ): - generator = WorkflowAppGenerator() + loader = Mock(spec=VariableLoader) + loader_factory = Mock(return_value=loader) + generator = WorkflowAppGenerator(draft_variable_loader=loader_factory, runtime=workflow_runtime) app = _persist_app(sqlite_session) workflow = _persist_workflow(sqlite_session) user = _persist_end_user(sqlite_session) @@ -322,29 +269,11 @@ def test_single_iteration_generate_includes_trace_session_id_in_extras( workflow_id=WORKFLOW_ID, ) captured: dict[str, object] = {} - repository_session_makers: list[sessionmaker[Session]] = [] - draft_sessions: list[Session] = [] monkeypatch.setattr( - "core.app.apps.workflow.app_generator.WorkflowAppConfigManager.get_app_config", + "services.workflow.execution.adapters.workflow.app_generator.WorkflowAppConfigManager.get_app_config", lambda **kwargs: app_config, ) - monkeypatch.setattr( - "core.app.apps.workflow.app_generator.DifyCoreRepositoryFactory.create_workflow_execution_repository", - lambda **kwargs: repository_session_makers.append(kwargs["session_factory"]) or SimpleNamespace(), - ) - monkeypatch.setattr( - "core.app.apps.workflow.app_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", - lambda **kwargs: repository_session_makers.append(kwargs["session_factory"]) or SimpleNamespace(), - ) - monkeypatch.setattr("core.app.apps.workflow.app_generator.DraftVarLoader", lambda **kwargs: SimpleNamespace()) - monkeypatch.setattr( - "core.app.apps.workflow.app_generator.WorkflowDraftVariableService", - lambda session: ( - draft_sessions.append(session) - or SimpleNamespace(prefill_conversation_variable_default_values=lambda *args, **kwargs: None) - ), - ) monkeypatch.setattr(generator, "_generate", lambda **kwargs: captured.update(kwargs) or {"ok": True}) generator.single_iteration_generate( @@ -354,22 +283,24 @@ def test_single_iteration_generate_includes_trace_session_id_in_extras( user=user, args={"inputs": {"foo": "bar"}, "trace_session_id": "session-1"}, streaming=False, - session=sqlite_session, ) assert captured["application_generate_entity"].extras["trace_session_id"] == "session-1" - assert len(repository_session_makers) == 2 - assert all(factory.kw["bind"] is sqlite_session.get_bind() for factory in repository_session_makers) - assert len(draft_sessions) == 1 - assert draft_sessions[0] is sqlite_session + assert generator._runtime is workflow_runtime + + loader_factory.assert_called_once_with(workflow, user.id) + assert captured["variable_loader"] is loader - @pytest.mark.usefixtures("sqlite_generator_session") def test_single_loop_generate_includes_trace_session_id_in_extras( self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, + *, + workflow_runtime: WorkflowRuntime, ): - generator = WorkflowAppGenerator() + loader = Mock(spec=VariableLoader) + loader_factory = Mock(return_value=loader) + generator = WorkflowAppGenerator(draft_variable_loader=loader_factory, runtime=workflow_runtime) app = _persist_app(sqlite_session) workflow = _persist_workflow(sqlite_session) user = _persist_end_user(sqlite_session) @@ -382,29 +313,11 @@ def test_single_loop_generate_includes_trace_session_id_in_extras( workflow_id=WORKFLOW_ID, ) captured: dict[str, object] = {} - repository_session_makers: list[sessionmaker[Session]] = [] - draft_sessions: list[Session] = [] monkeypatch.setattr( - "core.app.apps.workflow.app_generator.WorkflowAppConfigManager.get_app_config", + "services.workflow.execution.adapters.workflow.app_generator.WorkflowAppConfigManager.get_app_config", lambda **kwargs: app_config, ) - monkeypatch.setattr( - "core.app.apps.workflow.app_generator.DifyCoreRepositoryFactory.create_workflow_execution_repository", - lambda **kwargs: repository_session_makers.append(kwargs["session_factory"]) or SimpleNamespace(), - ) - monkeypatch.setattr( - "core.app.apps.workflow.app_generator.DifyCoreRepositoryFactory.create_workflow_node_execution_repository", - lambda **kwargs: repository_session_makers.append(kwargs["session_factory"]) or SimpleNamespace(), - ) - monkeypatch.setattr("core.app.apps.workflow.app_generator.DraftVarLoader", lambda **kwargs: SimpleNamespace()) - monkeypatch.setattr( - "core.app.apps.workflow.app_generator.WorkflowDraftVariableService", - lambda session: ( - draft_sessions.append(session) - or SimpleNamespace(prefill_conversation_variable_default_values=lambda *args, **kwargs: None) - ), - ) monkeypatch.setattr(generator, "_generate", lambda **kwargs: captured.update(kwargs) or {"ok": True}) generator.single_loop_generate( @@ -412,16 +325,12 @@ def test_single_loop_generate_includes_trace_session_id_in_extras( workflow=workflow, node_id="node-2", user=user, - args=SimpleNamespace(inputs={"foo": "bar"}, trace_session_id="session-1"), + args={"inputs": {"foo": "bar"}, "trace_session_id": "session-1"}, streaming=False, - session=sqlite_session, ) assert captured["application_generate_entity"].extras["trace_session_id"] == "session-1" - assert len(repository_session_makers) == 2 - assert all(factory.kw["bind"] is sqlite_session.get_bind() for factory in repository_session_makers) - assert len(draft_sessions) == 1 - assert draft_sessions[0] is sqlite_session + assert generator._runtime is workflow_runtime with pytest.raises(ValueError, match="inputs is required"): generator.single_loop_generate( @@ -429,15 +338,19 @@ def test_single_loop_generate_includes_trace_session_id_in_extras( workflow=_workflow(), node_id="node", user=_end_user(), - args=SimpleNamespace(inputs=None), + args={"inputs": None}, streaming=False, - session=sqlite_session, ) + loader_factory.assert_called_once_with(workflow, user.id) + assert captured["variable_loader"] is loader + class TestWorkflowAppGeneratorHandleResponse: - def test_handle_response_closed_file_raises_stopped(self, monkeypatch: pytest.MonkeyPatch): - generator = WorkflowAppGenerator() + def test_handle_response_closed_file_raises_stopped( + self, monkeypatch: pytest.MonkeyPatch, *, workflow_runtime: WorkflowRuntime + ): + generator = WorkflowAppGenerator(runtime=workflow_runtime) app_config = WorkflowUIBasedAppConfig( tenant_id="tenant", @@ -469,7 +382,7 @@ def process(self): raise ValueError("I/O operation on closed file.") monkeypatch.setattr( - "core.app.apps.workflow.app_generator.WorkflowAppGenerateTaskPipeline", + "services.workflow.execution.adapters.workflow.app_generator.WorkflowAppGenerateTaskPipeline", _Pipeline, ) @@ -486,12 +399,18 @@ def process(self): class TestWorkflowAppGeneratorGenerate: @pytest.fixture - def generation(self, monkeypatch: pytest.MonkeyPatch, sqlite_generator_session: Session): + def generation( + self, + monkeypatch: pytest.MonkeyPatch, + sqlite_session: Session, + *, + workflow_runtime: WorkflowRuntime, + ): """Keep input preparation real and stop at the execution boundary.""" - generator = WorkflowAppGenerator() - app = _persist_app(sqlite_generator_session) - workflow = _persist_workflow(sqlite_generator_session) - user = _persist_end_user(sqlite_generator_session) + generator = WorkflowAppGenerator(runtime=workflow_runtime) + app = _persist_app(sqlite_session) + workflow = _persist_workflow(sqlite_session) + user = _persist_end_user(sqlite_session) monkeypatch.setattr(app_generator_module, "TraceQueueManager", Mock(return_value=Mock(spec=TraceQueueManager))) execute = Mock(return_value={"ok": True}) monkeypatch.setattr(generator, "_generate", execute) @@ -577,8 +496,10 @@ def test_generate_prepares_start_inputs(self, generation, root_node_id): class TestWorkflowAppGeneratorResume: - def test_resume_restores_trace_manager_when_missing(self, monkeypatch: pytest.MonkeyPatch): - generator = WorkflowAppGenerator() + def test_resume_restores_trace_manager_when_missing( + self, monkeypatch: pytest.MonkeyPatch, *, workflow_runtime: WorkflowRuntime + ): + generator = WorkflowAppGenerator(runtime=workflow_runtime) app_config = WorkflowUIBasedAppConfig( tenant_id="tenant", app_id="app", @@ -610,7 +531,7 @@ def test_resume_restores_trace_manager_when_missing(self, monkeypatch: pytest.Mo }, ) monkeypatch.setattr( - "core.app.apps.workflow.app_generator.TraceQueueManager", + "services.workflow.execution.adapters.workflow.app_generator.TraceQueueManager", DummyTraceQueueManager, ) captured_entity: WorkflowAppGenerateEntity | None = None @@ -639,8 +560,10 @@ def _fake_generate(**kwargs): assert trace_manager.app_id == APP_ID assert trace_manager.user_id == "session-id" - def test_resume_preserves_existing_trace_manager(self, monkeypatch: pytest.MonkeyPatch): - generator = WorkflowAppGenerator() + def test_resume_preserves_existing_trace_manager( + self, monkeypatch: pytest.MonkeyPatch, *, workflow_runtime: WorkflowRuntime + ): + generator = WorkflowAppGenerator(runtime=workflow_runtime) app_config = WorkflowUIBasedAppConfig( tenant_id="tenant", app_id="app", @@ -692,11 +615,15 @@ def test_generate_worker_uses_end_user_session_for_external_invocation( self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, + *, + workflow_runtime: WorkflowRuntime, ): - generator = WorkflowAppGenerator() + resolver = WorkflowAgentBindingResolver(sessionmaker(bind=sqlite_session.get_bind())) + generator = WorkflowAppGenerator(runtime=workflow_runtime) _persist_app(sqlite_session) - _persist_workflow(sqlite_session) + workflow = _persist_workflow(sqlite_session) _persist_end_user(sqlite_session) + sqlite_session.expunge(workflow) runner_kwargs = {} @@ -708,12 +635,12 @@ def run(self): return None monkeypatch.setattr( - "core.app.apps.workflow.app_generator.preserve_flask_contexts", + "services.workflow.execution.adapters.workflow.app_generator.preserve_flask_contexts", lambda flask_app, context_vars: contextlib.nullcontext(), ) - monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppRunner", _Runner) + monkeypatch.setattr("services.workflow.execution.adapters.workflow.app_generator.WorkflowAppRunner", _Runner) restore_workflow_run_graph = Mock() - monkeypatch.setattr(generator, "_restore_workflow_run_graph", restore_workflow_run_graph) + monkeypatch.setattr(generator._runtime.contexts, "restore_graph", restore_workflow_run_graph) app_config = WorkflowUIBasedAppConfig( tenant_id=TENANT_ID, @@ -738,6 +665,8 @@ def run(self): ) generator._generate_worker( + system_user_id="session-id", + workflow=workflow, flask_app=SimpleNamespace(), application_generate_entity=application_generate_entity, queue_manager=SimpleNamespace(), @@ -749,9 +678,8 @@ def run(self): ) assert runner_kwargs["system_user_id"] == "session-id" + assert runner_kwargs["runtime"] is workflow_runtime + assert runner_kwargs["workflow"] is workflow restore_workflow_run_graph.assert_called_once() - restore_kwargs = restore_workflow_run_graph.call_args.kwargs - assert isinstance(restore_kwargs["session"], Session) - assert restore_kwargs["workflow"] is runner_kwargs["workflow"] - assert restore_kwargs["workflow_run_id"] == "run-id" + restore_workflow_run_graph.assert_called_once_with(workflow, "run-id") assert inspect(runner_kwargs["workflow"]).detached is True diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_command_channels.py b/api/tests/unit_tests/core/app/apps/workflow/test_command_channels.py index f555fe945c1878..c701feda418d6f 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_command_channels.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_command_channels.py @@ -5,12 +5,12 @@ import pytest -from core.app.apps.workflow.command_channels import ( +from graphon.graph_engine.entities.commands import AbortCommand, PauseCommand +from services.workflow.execution.adapters.workflow.command_channels import ( CelerySignalCommandChannel, CombinedCommandChannel, StopFlagCommandChannel, ) -from graphon.graph_engine.entities.commands import AbortCommand, PauseCommand class _CommandChannelStub: @@ -100,10 +100,14 @@ def test_celery_signal_command_channel_emits_abort_once_per_instance() -> None: def test_stop_flag_command_channel_emits_abort_when_flag_is_set() -> None: channel = StopFlagCommandChannel(task_id="task-1", abort_reason="User requested stop") - with patch("core.app.apps.workflow.command_channels.is_app_task_stop_flag_set", return_value=False): + with patch( + "services.workflow.execution.adapters.workflow.command_channels.is_app_task_stop_flag_set", return_value=False + ): assert channel.fetch_commands() == [] - with patch("core.app.apps.workflow.command_channels.is_app_task_stop_flag_set", return_value=True): + with patch( + "services.workflow.execution.adapters.workflow.command_channels.is_app_task_stop_flag_set", return_value=True + ): commands = channel.fetch_commands() assert len(commands) == 1 @@ -114,6 +118,8 @@ def test_stop_flag_command_channel_emits_abort_when_flag_is_set() -> None: def test_stop_flag_command_channel_emits_abort_once_per_instance() -> None: channel = StopFlagCommandChannel(task_id="task-1") - with patch("core.app.apps.workflow.command_channels.is_app_task_stop_flag_set", return_value=True): + with patch( + "services.workflow.execution.adapters.workflow.command_channels.is_app_task_stop_flag_set", return_value=True + ): assert len(channel.fetch_commands()) == 1 assert channel.fetch_commands() == [] diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py index eee3229c500cba..5b407dc1141802 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline.py @@ -1,98 +1,149 @@ -import json -import time -from unittest.mock import MagicMock +from dataclasses import dataclass -from sqlalchemy.orm import Session, sessionmaker +import pytest +from sqlalchemy import create_engine, event, select +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import QueuePool from core.app.app_config.entities import WorkflowUIBasedAppConfig -from core.app.apps.base_app_queue_manager import AppQueueManager -from core.app.apps.workflow.generate_task_pipeline import WorkflowAppGenerateTaskPipeline +from core.app.apps.draft_variable_saver import NoopDraftVariableSaver from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity from core.app.entities.queue_entities import QueueWorkflowStartedEvent from core.workflow.system_variables import build_system_variables +from extensions.application_services.workflow import build_workflow_execution_dependencies from graphon.entities import WorkflowStartReason from graphon.runtime import GraphRuntimeState -from models.account import Account +from models.base import TypeBase +from models.enums import CreatorUserRole from models.model import AppMode -from models.workflow import Workflow, WorkflowType +from models.workflow import WorkflowAppLog +from services.workflow.execution.adapters.workflow.generate_task_pipeline import WorkflowAppGenerateTaskPipeline +from tests.unit_tests.model_factories import make_account, make_end_user, make_workflow from tests.workflow_test_utils import build_test_variable_pool -def _build_workflow_app_config() -> WorkflowUIBasedAppConfig: - return WorkflowUIBasedAppConfig( - tenant_id="tenant-id", - app_id="app-id", - app_mode=AppMode.WORKFLOW, - workflow_id="workflow-id", - ) +@dataclass +class WorkflowQueue: + invoke_from: InvokeFrom + graph_runtime_state: GraphRuntimeState + +@pytest.fixture +def log_store(): + # This database is deliberately separate from the application's global database. + engine = create_engine("sqlite://", poolclass=QueuePool) + TypeBase.metadata.create_all(engine, tables=[WorkflowAppLog.__table__]) + sessions = sessionmaker(engine, expire_on_commit=False) + try: + yield sessions, build_workflow_execution_dependencies(sessions) + finally: + engine.dispose() -def _build_generate_entity(run_id: str) -> WorkflowAppGenerateEntity: - return WorkflowAppGenerateEntity( + +def build_pipeline(runtime, invoke_from, user, *, tool_providers): + workflow = make_workflow(workflow_id="workflow-id", app_id="app-id", tenant_id="tenant-id", features="{}") + entity = WorkflowAppGenerateEntity( task_id="task-id", - app_config=_build_workflow_app_config(), + app_config=WorkflowUIBasedAppConfig( + tenant_id=workflow.tenant_id, + app_id=workflow.app_id, + app_mode=AppMode.WORKFLOW, + workflow_id=workflow.id, + ), inputs={}, files=[], - user_id="user-id", + user_id=user.id, stream=False, - invoke_from=InvokeFrom.SERVICE_API, - workflow_execution_id=run_id, + invoke_from=invoke_from, + workflow_execution_id="run-id", ) - - -def _build_runtime_state(run_id: str) -> GraphRuntimeState: - variable_pool = build_test_variable_pool(variables=build_system_variables(workflow_execution_id=run_id)) - return GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter()) - - -def _build_pipeline(run_id: str, unbound_session_factory: sessionmaker[Session]) -> WorkflowAppGenerateTaskPipeline: - queue_manager = MagicMock(spec=AppQueueManager) - queue_manager.invoke_from = InvokeFrom.SERVICE_API - queue_manager.graph_runtime_state = _build_runtime_state(run_id) - workflow = Workflow( - id="workflow-id", - tenant_id="tenant-id", - app_id="app-id", - type=WorkflowType.WORKFLOW, - version=Workflow.VERSION_DRAFT, - graph="{}", - features=json.dumps({}), - created_by="user-id", + state = GraphRuntimeState( + variable_pool=build_test_variable_pool(variables=build_system_variables(workflow_execution_id="run-id")), + start_at=0, ) - user = Account(name="user", email="user@example.com") - pipeline = WorkflowAppGenerateTaskPipeline( - application_generate_entity=_build_generate_entity(run_id), + return WorkflowAppGenerateTaskPipeline( + contexts=runtime.contexts, + logs=runtime.logs, + application_generate_entity=entity, workflow=workflow, - queue_manager=queue_manager, + queue_manager=WorkflowQueue(invoke_from, state), user=user, stream=False, - draft_var_saver_factory=MagicMock(), + draft_var_saver_factory=lambda **_kwargs: NoopDraftVariableSaver(), + tool_providers=tool_providers, ) - pipeline._database_session = unbound_session_factory - return pipeline - -def test_workflow_app_log_saved_only_on_initial_start(unbound_session_factory: sessionmaker[Session]) -> None: - run_id = "run-initial" - pipeline = _build_pipeline(run_id, unbound_session_factory) - pipeline._save_workflow_app_log = MagicMock() - event = QueueWorkflowStartedEvent(reason=WorkflowStartReason.INITIAL) - list(pipeline._handle_workflow_started_event(event)) - - pipeline._save_workflow_app_log.assert_called_once() - _, kwargs = pipeline._save_workflow_app_log.call_args - assert kwargs["workflow_run_id"] == run_id - assert pipeline._workflow_execution_id == run_id +@pytest.mark.parametrize( + ("invoke_from", "created_from"), + [ + (InvokeFrom.SERVICE_API, "service-api"), + (InvokeFrom.OPENAPI, "openapi"), + (InvokeFrom.EXPLORE, "installed-app"), + (InvokeFrom.WEB_APP, "web-app"), + ], +) +@pytest.mark.parametrize("account", [True, False]) +def test_start_commits_log_to_injected_database_before_response( + log_store, invoke_from, created_from, account, *, tool_providers +): + sessions, runtime = log_store + user = make_account(account_id="user-id") if account else make_end_user(end_user_id="user-id") + pipeline = build_pipeline(runtime, invoke_from, user, tool_providers=tool_providers) + responses = pipeline._handle_workflow_started_event(QueueWorkflowStartedEvent(reason=WorkflowStartReason.INITIAL)) + + assert next(responses).workflow_run_id == "run-id" + assert sessions.kw["bind"].pool.checkedout() == 0 + with sessions() as session: + log = session.scalars(select(WorkflowAppLog)).one() + assert (log.tenant_id, log.app_id, log.workflow_id, log.workflow_run_id) == ( + "tenant-id", + "app-id", + "workflow-id", + "run-id", + ) + assert log.created_from == created_from + assert log.created_by == user.id + assert log.created_by_role == (CreatorUserRole.ACCOUNT if account else CreatorUserRole.END_USER) + assert list(responses) == [] + + list(pipeline._handle_workflow_started_event(QueueWorkflowStartedEvent(reason=WorkflowStartReason.RESUMPTION))) + with sessions() as session: + assert session.scalars(select(WorkflowAppLog)).one().id == log.id + + +@pytest.mark.parametrize( + "invoke_from", [InvokeFrom.DEBUGGER, InvokeFrom.TRIGGER, InvokeFrom.PUBLISHED_PIPELINE, InvokeFrom.VALIDATION] +) +def test_debug_and_pipeline_starts_do_not_create_application_logs(log_store, invoke_from, *, tool_providers): + sessions, runtime = log_store + pipeline = build_pipeline(runtime, invoke_from, make_account(account_id="user-id"), tool_providers=tool_providers) + assert len(list(pipeline._handle_workflow_started_event(QueueWorkflowStartedEvent()))) == 1 + with sessions() as session: + assert session.scalar(select(WorkflowAppLog)) is None + + +def test_log_failure_rolls_back_and_releases_connection_for_retry(log_store, *, tool_providers): + sessions, runtime = log_store + pipeline = build_pipeline( + runtime, InvokeFrom.SERVICE_API, make_account(account_id="user-id"), tool_providers=tool_providers + ) + def fail_after_insert(session, _flush_context): + assert session.scalar(select(WorkflowAppLog)) is not None + raise RuntimeError("commit failed") -def test_workflow_app_log_skipped_on_resumption_start(unbound_session_factory: sessionmaker[Session]) -> None: - run_id = "run-resume" - pipeline = _build_pipeline(run_id, unbound_session_factory) - pipeline._save_workflow_app_log = MagicMock() + event.listen(sessions, "after_flush", fail_after_insert) + try: + with pytest.raises(RuntimeError, match="commit failed"): + list(pipeline._handle_workflow_started_event(QueueWorkflowStartedEvent())) + finally: + event.remove(sessions, "after_flush", fail_after_insert) - event = QueueWorkflowStartedEvent(reason=WorkflowStartReason.RESUMPTION) - list(pipeline._handle_workflow_started_event(event)) + assert sessions.kw["bind"].pool.checkedout() == 0 + with sessions() as session: + assert session.scalar(select(WorkflowAppLog)) is None - pipeline._save_workflow_app_log.assert_not_called() - assert pipeline._workflow_execution_id == run_id + assert len(list(pipeline._handle_workflow_started_event(QueueWorkflowStartedEvent()))) == 1 + with sessions() as session: + assert session.scalars(select(WorkflowAppLog)).one().workflow_run_id == "run-id" diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py index b63e323af1a811..a1d1980ccecc64 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_generate_task_pipeline_core.py @@ -7,7 +7,6 @@ from sqlalchemy.orm import Session from core.app.app_config.entities import AppAdditionalFeatures, WorkflowUIBasedAppConfig -from core.app.apps.workflow.generate_task_pipeline import WorkflowAppGenerateTaskPipeline from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity from core.app.entities.queue_entities import ( QueueAgentLogEvent, @@ -55,6 +54,7 @@ from models.enums import CreatorUserRole from models.model import AppMode, EndUser from models.workflow import Workflow, WorkflowAppLog +from services.workflow.execution.adapters.workflow.generate_task_pipeline import WorkflowAppGenerateTaskPipeline from tests.unit_tests.model_factories import make_end_user, make_workflow from tests.workflow_test_utils import build_test_variable_pool @@ -67,7 +67,7 @@ def _end_user(*, end_user_id: str = "user", session_id: str = "session") -> EndU return make_end_user(end_user_id=end_user_id, tenant_id="tenant", app_id="app", session_id=session_id) -def _make_pipeline(): +def _make_pipeline(*, workflow_runtime, tool_providers): app_config = WorkflowUIBasedAppConfig( tenant_id="tenant", app_id="app", @@ -93,20 +93,25 @@ def _make_pipeline(): user = _end_user() pipeline = WorkflowAppGenerateTaskPipeline( + contexts=workflow_runtime.contexts, + logs=workflow_runtime.logs, application_generate_entity=application_generate_entity, workflow=workflow, queue_manager=SimpleNamespace(invoke_from=InvokeFrom.WEB_APP, graph_runtime_state=None), user=user, stream=False, draft_var_saver_factory=lambda **kwargs: None, + tool_providers=tool_providers, ) return pipeline class TestWorkflowGenerateTaskPipeline: - def test_to_blocking_response_falls_back_to_human_input_required_when_pause_event_missing(self): - pipeline = _make_pipeline() + def test_to_blocking_response_falls_back_to_human_input_required_when_pause_event_missing( + self, *, workflow_runtime, tool_providers + ): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) pipeline._graph_runtime_state = GraphRuntimeState( variable_pool=build_test_variable_pool( variables=build_system_variables(workflow_execution_id="run-id"), @@ -153,8 +158,8 @@ def _gen(): } ] - def test_to_blocking_response_handles_finish(self): - pipeline = _make_pipeline() + def test_to_blocking_response_handles_finish(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) def _gen(): yield WorkflowFinishStreamResponse( @@ -178,24 +183,24 @@ def _gen(): assert response.data.outputs == {"ok": True} - def test_listen_audio_msg_returns_audio_stream(self): - pipeline = _make_pipeline() + def test_listen_audio_msg_returns_audio_stream(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) publisher = SimpleNamespace(check_and_get_audio=lambda: AudioTrunk(status="responding", audio="data")) response = pipeline._listen_audio_msg(publisher=publisher, task_id="task") assert isinstance(response, MessageAudioStreamResponse) - def test_handle_ping_event(self): - pipeline = _make_pipeline() + def test_handle_ping_event(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) pipeline._base_task_pipeline.ping_stream_response = lambda: PingStreamResponse(task_id="task") responses = list(pipeline._handle_ping_event(QueuePingEvent())) assert isinstance(responses[0], PingStreamResponse) - def test_handle_error_event(self): - pipeline = _make_pipeline() + def test_handle_error_event(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) pipeline._base_task_pipeline.handle_error = lambda **kwargs: ValueError("boom") pipeline._base_task_pipeline.error_to_stream_response = lambda err: err @@ -203,18 +208,16 @@ def test_handle_error_event(self): assert isinstance(responses[0], ValueError) - def test_handle_workflow_started_event_sets_run_id(self, monkeypatch: pytest.MonkeyPatch, sqlite_engine): - pipeline = _make_pipeline() + def test_handle_workflow_started_event_sets_run_id( + self, monkeypatch: pytest.MonkeyPatch, *, workflow_runtime, tool_providers + ): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) pipeline._graph_runtime_state = GraphRuntimeState( variable_pool=build_test_variable_pool(variables=build_system_variables(workflow_execution_id="run-id")), start_at=0.0, ) pipeline._workflow_response_converter.workflow_start_to_stream_response = lambda **kwargs: "started" - monkeypatch.setattr( - "core.app.apps.workflow.generate_task_pipeline.db", - SimpleNamespace(engine=sqlite_engine), - ) monkeypatch.setattr(pipeline, "_save_workflow_app_log", lambda **kwargs: None) responses = list(pipeline._handle_workflow_started_event(QueueWorkflowStartedEvent())) @@ -222,8 +225,8 @@ def test_handle_workflow_started_event_sets_run_id(self, monkeypatch: pytest.Mon assert pipeline._workflow_execution_id == "run-id" assert responses == ["started"] - def test_handle_node_succeeded_event_saves_output(self): - pipeline = _make_pipeline() + def test_handle_node_succeeded_event_saves_output(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "done" pipeline._save_output_for_event = lambda event, node_execution_id: None pipeline._workflow_execution_id = "run-id" @@ -242,8 +245,8 @@ def test_handle_node_succeeded_event_saves_output(self): assert responses == ["done"] - def test_handle_workflow_failed_event_yields_error(self): - pipeline = _make_pipeline() + def test_handle_workflow_failed_event_yields_error(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) pipeline._workflow_execution_id = "run-id" pipeline._graph_runtime_state = GraphRuntimeState( variable_pool=build_test_variable_pool(variables=build_system_variables(workflow_execution_id="run-id")), @@ -259,8 +262,8 @@ def test_handle_workflow_failed_event_yields_error(self): assert responses[0] == "finish" - def test_handle_text_chunk_event_publishes_tts(self): - pipeline = _make_pipeline() + def test_handle_text_chunk_event_publishes_tts(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) published: list[object] = [] class _Publisher: @@ -277,8 +280,8 @@ def publish(self, message): assert responses[0].data.text == "hi" assert published == [queue_message] - def test_handle_reasoning_chunk_event_emits_on_nonempty(self): - pipeline = _make_pipeline() + def test_handle_reasoning_chunk_event_emits_on_nonempty(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) event = QueueReasoningChunkEvent(reasoning="pondering", from_node_id="llm-1", is_final=False) responses = list(pipeline._handle_reasoning_chunk_event(event)) @@ -292,16 +295,16 @@ def test_handle_reasoning_chunk_event_emits_on_nonempty(self): assert response.data.node_id == "llm-1" assert response.data.is_final is False - def test_handle_reasoning_chunk_event_drops_empty_nonfinal(self): - pipeline = _make_pipeline() + def test_handle_reasoning_chunk_event_drops_empty_nonfinal(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) event = QueueReasoningChunkEvent(reasoning="", from_node_id="llm-1", is_final=False) responses = list(pipeline._handle_reasoning_chunk_event(event)) assert responses == [] - def test_handle_reasoning_chunk_event_emits_empty_final_marker(self): - pipeline = _make_pipeline() + def test_handle_reasoning_chunk_event_emits_empty_final_marker(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) event = QueueReasoningChunkEvent(reasoning="", from_node_id="llm-1", is_final=True) responses = list(pipeline._handle_reasoning_chunk_event(event)) @@ -312,8 +315,8 @@ def test_handle_reasoning_chunk_event_emits_empty_final_marker(self): assert response.data.reasoning == "" assert response.data.is_final is True - def test_dispatch_event_handles_node_failed(self): - pipeline = _make_pipeline() + def test_dispatch_event_handles_node_failed(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "done" event = QueueNodeFailedEvent( @@ -329,8 +332,8 @@ def test_dispatch_event_handles_node_failed(self): assert list(pipeline._dispatch_event(event)) == ["done"] - def test_handle_stop_event_yields_finish(self): - pipeline = _make_pipeline() + def test_handle_stop_event_yields_finish(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) pipeline._workflow_execution_id = "run-id" pipeline._graph_runtime_state = GraphRuntimeState( variable_pool=VariablePool.from_bootstrap( @@ -349,11 +352,11 @@ def test_handle_stop_event_yields_finish(self): assert responses == ["finish"] @pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True) - def test_save_workflow_app_log_created_from(self, sqlite_session: Session): - pipeline = _make_pipeline() + def test_save_workflow_app_log_created_from(self, sqlite_session: Session, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) pipeline._application_generate_entity.invoke_from = InvokeFrom.SERVICE_API pipeline._user_id = "user" - pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id") + pipeline._save_workflow_app_log(workflow_run_id="run-id") sqlite_session.flush() saved_log = sqlite_session.scalar(select(WorkflowAppLog)) @@ -361,8 +364,8 @@ def test_save_workflow_app_log_created_from(self, sqlite_session: Session): assert saved_log.workflow_run_id == "run-id" assert saved_log.created_from == "service-api" - def test_iteration_loop_and_human_input_handlers(self): - pipeline = _make_pipeline() + def test_iteration_loop_and_human_input_handlers(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) pipeline._workflow_execution_id = "run-id" pipeline._workflow_response_converter.workflow_iteration_start_to_stream_response = lambda **kwargs: "iter" pipeline._workflow_response_converter.workflow_iteration_next_to_stream_response = lambda **kwargs: "next" @@ -459,8 +462,10 @@ def test_iteration_loop_and_human_input_handlers(self): assert list(pipeline._handle_human_input_form_timeout_event(timeout_event)) == ["timeout"] assert list(pipeline._handle_agent_log_event(agent_event)) == ["log"] - def test_wrapper_process_stream_response_emits_audio_end(self, monkeypatch: pytest.MonkeyPatch): - pipeline = _make_pipeline() + def test_wrapper_process_stream_response_emits_audio_end( + self, monkeypatch: pytest.MonkeyPatch, *, workflow_runtime, tool_providers + ): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) pipeline._base_task_pipeline.stream = True pipeline._workflow_features_dict = { "text_to_speech": {"enabled": True, "autoPlay": "enabled", "voice": "v", "language": "en"} @@ -489,7 +494,7 @@ def cancel(self): return None monkeypatch.setattr( - "core.app.apps.workflow.generate_task_pipeline.AppGeneratorTTSPublisher", + "services.workflow.execution.adapters.workflow.generate_task_pipeline.AppGeneratorTTSPublisher", _Publisher, ) @@ -498,7 +503,7 @@ def cancel(self): assert any(isinstance(item, MessageAudioStreamResponse) for item in responses) assert any(isinstance(item, MessageAudioEndStreamResponse) for item in responses) - def test_init_with_end_user_sets_role_and_system_user(self): + def test_init_with_end_user_sets_role_and_system_user(self, *, workflow_runtime, tool_providers): app_config = WorkflowUIBasedAppConfig( tenant_id="tenant", app_id="app", @@ -525,19 +530,22 @@ def test_init_with_end_user_sets_role_and_system_user(self): end_user = _end_user(end_user_id="end-user-id", session_id="session-id") pipeline = WorkflowAppGenerateTaskPipeline( + contexts=workflow_runtime.contexts, + logs=workflow_runtime.logs, application_generate_entity=application_generate_entity, workflow=workflow, queue_manager=queue_manager, user=end_user, stream=False, draft_var_saver_factory=lambda **kwargs: None, + tool_providers=tool_providers, ) assert pipeline._created_by_role == CreatorUserRole.END_USER assert system_variables_to_mapping(pipeline._workflow_system_variables)["user_id"] == "session-id" - def test_process_returns_stream_and_blocking_variants(self): - pipeline = _make_pipeline() + def test_process_returns_stream_and_blocking_variants(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) pipeline._base_task_pipeline.stream = True pipeline._wrapper_process_stream_response = lambda **kwargs: iter([PingStreamResponse(task_id="task")]) @@ -570,8 +578,8 @@ def test_process_returns_stream_and_blocking_variants(self): blocking_response = pipeline.process() assert blocking_response.workflow_run_id == "run-id" - def test_to_blocking_response_handles_error_and_unexpected_end(self): - pipeline = _make_pipeline() + def test_to_blocking_response_handles_error_and_unexpected_end(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) def _error_gen(): yield ErrorStreamResponse(task_id="task", err=ValueError("boom")) @@ -585,8 +593,8 @@ def _unexpected_gen(): with pytest.raises(ValueError, match="queue listening stopped unexpectedly"): pipeline._to_blocking_response(_unexpected_gen()) - def test_to_stream_response_tracks_workflow_run_id(self): - pipeline = _make_pipeline() + def test_to_stream_response_tracks_workflow_run_id(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) def _gen(): yield WorkflowStartStreamResponse( @@ -605,20 +613,22 @@ def _gen(): assert stream_responses[0].workflow_run_id == "run-id" assert stream_responses[1].workflow_run_id == "run-id" - def test_listen_audio_msg_returns_none_without_publisher(self): - pipeline = _make_pipeline() + def test_listen_audio_msg_returns_none_without_publisher(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) assert pipeline._listen_audio_msg(publisher=None, task_id="task") is None - def test_wrapper_process_stream_response_without_tts(self): - pipeline = _make_pipeline() + def test_wrapper_process_stream_response_without_tts(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) pipeline._workflow_features_dict = {} pipeline._process_stream_response = lambda **kwargs: iter([PingStreamResponse(task_id="task")]) responses = list(pipeline._wrapper_process_stream_response()) assert responses == [PingStreamResponse(task_id="task")] - def test_wrapper_process_stream_response_uses_a_blocking_terminal_read(self, monkeypatch: pytest.MonkeyPatch): - pipeline = _make_pipeline() + def test_wrapper_process_stream_response_uses_a_blocking_terminal_read( + self, monkeypatch: pytest.MonkeyPatch, *, workflow_runtime, tool_providers + ): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) pipeline._base_task_pipeline.stream = True pipeline._workflow_features_dict = { "text_to_speech": {"enabled": True, "autoPlay": "enabled", "voice": "v", "language": "en"} @@ -642,7 +652,7 @@ def cancel(self): return None monkeypatch.setattr( - "core.app.apps.workflow.generate_task_pipeline.AppGeneratorTTSPublisher", + "services.workflow.execution.adapters.workflow.generate_task_pipeline.AppGeneratorTTSPublisher", _Publisher, ) @@ -652,9 +662,9 @@ def cancel(self): assert any(isinstance(item, MessageAudioEndStreamResponse) for item in responses) def test_wrapper_process_stream_response_does_not_swallow_audio_queue_exceptions( - self, monkeypatch: pytest.MonkeyPatch + self, monkeypatch: pytest.MonkeyPatch, *, workflow_runtime, tool_providers ): - pipeline = _make_pipeline() + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) pipeline._base_task_pipeline.stream = True pipeline._workflow_features_dict = { "text_to_speech": {"enabled": True, "autoPlay": "enabled", "voice": "v", "language": "en"} @@ -676,39 +686,15 @@ def cancel(self): return None monkeypatch.setattr( - "core.app.apps.workflow.generate_task_pipeline.AppGeneratorTTSPublisher", + "services.workflow.execution.adapters.workflow.generate_task_pipeline.AppGeneratorTTSPublisher", _Publisher, ) with pytest.raises(RuntimeError, match="tts failure"): list(pipeline._wrapper_process_stream_response()) - @pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True) - def test_database_session_rolls_back_on_error( - self, monkeypatch: pytest.MonkeyPatch, sqlite_engine, sqlite_session: Session - ): - pipeline = _make_pipeline() - pipeline._application_generate_entity.invoke_from = InvokeFrom.SERVICE_API - pipeline._user_id = "user" - monkeypatch.setattr( - "core.app.apps.workflow.generate_task_pipeline.db", - SimpleNamespace(engine=sqlite_engine), - ) - - def persist_then_fail() -> None: - with pipeline._database_session() as session: - pipeline._save_workflow_app_log(session=session, workflow_run_id="run-id") - session.flush() - raise RuntimeError("db error") - - with pytest.raises(RuntimeError, match="db error"): - persist_then_fail() - - sqlite_session.expire_all() - assert sqlite_session.scalar(select(WorkflowAppLog)) is None - - def test_node_retry_and_started_handlers_cover_none_and_value(self): - pipeline = _make_pipeline() + def test_node_retry_and_started_handlers_cover_none_and_value(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) pipeline._workflow_execution_id = "run-id" retry_event = QueueNodeRetryEvent( @@ -744,8 +730,8 @@ def test_node_retry_and_started_handlers_cover_none_and_value(self): pipeline._workflow_response_converter.workflow_node_start_to_stream_response = lambda **kwargs: "started" assert list(pipeline._handle_node_started_event(started_event)) == ["started"] - def test_handle_node_exception_event_saves_output(self): - pipeline = _make_pipeline() + def test_handle_node_exception_event_saves_output(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) saved_ids: list[str] = [] pipeline._workflow_response_converter.workflow_node_finish_to_stream_response = lambda **kwargs: "failed" pipeline._save_output_for_event = lambda event, node_execution_id: saved_ids.append(node_execution_id) @@ -765,8 +751,8 @@ def test_handle_node_exception_event_saves_output(self): assert responses == ["failed"] assert saved_ids == ["exec-id"] - def test_success_partial_and_pause_handlers(self): - pipeline = _make_pipeline() + def test_success_partial_and_pause_handlers(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) pipeline._workflow_execution_id = "run-id" pipeline._graph_runtime_state = GraphRuntimeState( variable_pool=VariablePool.from_bootstrap( @@ -790,13 +776,13 @@ def test_success_partial_and_pause_handlers(self): pause_event = QueueWorkflowPausedEvent(reasons=[], outputs={}, paused_nodes=["node"]) assert list(pipeline._handle_workflow_paused_event(pause_event)) == ["pause-a", "pause-b"] - def test_text_chunk_handler_returns_empty_when_text_missing(self): - pipeline = _make_pipeline() + def test_text_chunk_handler_returns_empty_when_text_missing(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) event = QueueTextChunkEvent.model_construct(text=None, from_variable_selector=None) assert list(pipeline._handle_text_chunk_event(event)) == [] - def test_dispatch_event_direct_failed_and_unhandled_paths(self): - pipeline = _make_pipeline() + def test_dispatch_event_direct_failed_and_unhandled_paths(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) pipeline._workflow_execution_id = "run-id" pipeline._graph_runtime_state = GraphRuntimeState( variable_pool=VariablePool.from_bootstrap( @@ -814,8 +800,8 @@ def test_dispatch_event_direct_failed_and_unhandled_paths(self): assert list(pipeline._dispatch_event(SimpleNamespace())) == [] - def test_process_stream_response_main_match_paths_and_cleanup(self): - pipeline = _make_pipeline() + def test_process_stream_response_main_match_paths_and_cleanup(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) pipeline._graph_runtime_state = GraphRuntimeState( variable_pool=VariablePool.from_bootstrap( system_variables=build_system_variables(workflow_execution_id="run-id") @@ -844,8 +830,8 @@ def publish(self, message): assert responses == ["started", "text", "dispatched", "error"] assert publisher_calls == [] - def test_process_stream_response_break_paths(self): - pipeline = _make_pipeline() + def test_process_stream_response_break_paths(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) pipeline._base_task_pipeline.queue_manager.listen = lambda: iter( [SimpleNamespace(event=QueueWorkflowFailedEvent(error="fail", exceptions_count=1))] @@ -866,32 +852,34 @@ def test_process_stream_response_break_paths(self): assert list(pipeline._process_stream_response()) == ["stopped"] @pytest.mark.parametrize("sqlite_session", [(WorkflowAppLog,)], indirect=True) - def test_save_workflow_app_log_covers_invoke_from_variants(self, sqlite_session: Session): - pipeline = _make_pipeline() + def test_save_workflow_app_log_covers_invoke_from_variants( + self, sqlite_session: Session, *, workflow_runtime, tool_providers + ): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) pipeline._user_id = "user-id" pipeline._application_generate_entity.invoke_from = InvokeFrom.EXPLORE - pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id") + pipeline._save_workflow_app_log(workflow_run_id="run-id") pipeline._application_generate_entity.invoke_from = InvokeFrom.WEB_APP - pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id-2") + pipeline._save_workflow_app_log(workflow_run_id="run-id-2") sqlite_session.flush() saved_logs = sqlite_session.scalars(select(WorkflowAppLog).order_by(WorkflowAppLog.workflow_run_id)).all() assert [log.created_from for log in saved_logs] == ["installed-app", "web-app"] count_before = len(saved_logs) pipeline._application_generate_entity.invoke_from = InvokeFrom.DEBUGGER - pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id="run-id-3") + pipeline._save_workflow_app_log(workflow_run_id="run-id-3") sqlite_session.flush() assert len(sqlite_session.scalars(select(WorkflowAppLog)).all()) == count_before pipeline._application_generate_entity.invoke_from = InvokeFrom.WEB_APP - pipeline._save_workflow_app_log(session=sqlite_session, workflow_run_id=None) + pipeline._save_workflow_app_log(workflow_run_id=None) sqlite_session.flush() assert len(sqlite_session.scalars(select(WorkflowAppLog)).all()) == count_before - def test_save_output_for_event_writes_draft_variables(self): - pipeline = _make_pipeline() + def test_save_output_for_event_writes_draft_variables(self, *, workflow_runtime, tool_providers): + pipeline = _make_pipeline(workflow_runtime=workflow_runtime, tool_providers=tool_providers) saver_calls: list[tuple[object, object]] = [] captured_factory_args: dict[str, object] = {} diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_stop_aware_ready_queue.py b/api/tests/unit_tests/core/app/apps/workflow/test_stop_aware_ready_queue.py index 6dc1781cc0037a..50757c31f2b9f8 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_stop_aware_ready_queue.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_stop_aware_ready_queue.py @@ -2,14 +2,14 @@ from unittest.mock import Mock, patch -from core.app.apps.workflow.stop_aware_ready_queue import ( - StopAwareReadyQueue, - attach_stop_aware_ready_queue, -) from graphon.graph_engine.domain.graph_execution import GraphExecution from graphon.graph_engine.ready_queue import StartTask from graphon.runtime.graph_runtime_state import GraphRuntimeState from graphon.runtime.variable_pool import VariablePool +from services.workflow.execution.adapters.workflow.stop_aware_ready_queue import ( + StopAwareReadyQueue, + attach_stop_aware_ready_queue, +) def _start_task(node_id: str = "next-node") -> StartTask: @@ -26,7 +26,7 @@ def test_ready_queue_accepts_work_while_run_is_active() -> None: task = _start_task() with patch( - "core.app.apps.workflow.stop_aware_ready_queue.is_app_task_stop_flag_set", + "services.workflow.execution.adapters.workflow.stop_aware_ready_queue.is_app_task_stop_flag_set", return_value=False, ): queue.put(task) @@ -39,7 +39,7 @@ def test_ready_queue_rejects_next_node_after_graph_abort() -> None: queue = StopAwareReadyQueue(inner, task_id="task-1", graph_execution=_graph_execution(aborted=True)) with patch( - "core.app.apps.workflow.stop_aware_ready_queue.is_app_task_stop_flag_set", + "services.workflow.execution.adapters.workflow.stop_aware_ready_queue.is_app_task_stop_flag_set", return_value=False, ): queue.put(_start_task()) @@ -52,7 +52,7 @@ def test_ready_queue_rejects_next_node_when_stop_flag_is_set() -> None: queue = StopAwareReadyQueue(inner, task_id="task-1", graph_execution=_graph_execution()) with patch( - "core.app.apps.workflow.stop_aware_ready_queue.is_app_task_stop_flag_set", + "services.workflow.execution.adapters.workflow.stop_aware_ready_queue.is_app_task_stop_flag_set", return_value=True, ) as stop_flag: queue.put(_start_task("code-node")) diff --git a/api/tests/unit_tests/core/app/task_pipeline/test_based_generate_task_pipeline.py b/api/tests/unit_tests/core/app/task_pipeline/test_based_generate_task_pipeline.py index 26b3f521a3e802..30af20fa6318e9 100644 --- a/api/tests/unit_tests/core/app/task_pipeline/test_based_generate_task_pipeline.py +++ b/api/tests/unit_tests/core/app/task_pipeline/test_based_generate_task_pipeline.py @@ -7,7 +7,6 @@ from sqlalchemy.orm import Session from clients.agent_backend.errors import AgentBackendRunFailedError -from core.app.apps.agent_app.errors import AgentSessionSnapshotIncompatibleError from core.app.apps.base_app_generate_response_converter import AppGenerateResponseConverter from core.app.entities.app_invoke_entities import InvokeFrom from core.app.entities.queue_entities import QueueErrorEvent @@ -16,6 +15,7 @@ from graphon.model_runtime.errors.invoke import InvokeAuthorizationError, InvokeError, InvokeRateLimitError from models.enums import ConversationFromSource, MessageStatus from models.model import AppMode, Message +from services.app.generation.errors import AgentSessionSnapshotIncompatibleError def _persist_message(session: Session, *, message_id: str) -> Message: @@ -97,34 +97,6 @@ def test_handle_error_preserves_agent_backend_run_failed_error(self, pipeline): assert "Knowledge retrieval failed" in str(err) assert "agent_run_id=run-1" in str(err) - @pytest.mark.parametrize("sqlite_session", [(Message,)], indirect=True) - def test_handle_error_updates_message_when_found(self, pipeline, sqlite_session: Session): - event = QueueErrorEvent(error=ValueError("oops")) - _persist_message(sqlite_session, message_id="msg-1") - - err = pipeline.handle_error(event=event, session=sqlite_session, message_id="msg-1") - - assert err is event.error - sqlite_session.flush() - sqlite_session.expire_all() - updated_message = sqlite_session.get(Message, "msg-1") - assert updated_message is not None - assert updated_message.status == MessageStatus.ERROR - assert updated_message.error == "oops" - - @pytest.mark.parametrize("sqlite_session", [(Message,)], indirect=True) - def test_handle_error_returns_err_when_message_missing(self, pipeline, sqlite_session: Session): - event = QueueErrorEvent(error=ValueError("oops")) - _persist_message(sqlite_session, message_id="other-message") - - err = pipeline.handle_error(event=event, session=sqlite_session, message_id="msg-1") - - assert err is event.error - untouched_message = sqlite_session.get(Message, "other-message") - assert untouched_message is not None - assert untouched_message.status == MessageStatus.NORMAL - assert untouched_message.error is None - def test_error_to_stream_response_and_ping(self, pipeline): error_response = pipeline.error_to_stream_response(ValueError("boom")) ping_response = pipeline.ping_stream_response() diff --git a/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline.py b/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline.py index ad4d6a5563164d..09bfb3d77b865f 100644 --- a/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline.py +++ b/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline.py @@ -35,7 +35,6 @@ PingStreamResponse, StreamEvent, ) -from core.app.task_pipeline.easy_ui_based_generate_task_pipeline import EasyUIBasedGenerateTaskPipeline from core.base.tts import AppGeneratorTTSPublisher from core.ops.ops_trace_manager import TraceQueueManager from graphon.model_runtime.entities.llm_entities import LLMResult as RuntimeLLMResult @@ -43,6 +42,7 @@ from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, TextPromptMessageContent from models.enums import ConversationFromSource from models.model import AppMode, Conversation, Message +from services.app.generation.adapters.message_pipeline import EasyUIBasedGenerateTaskPipeline from tests.unit_tests.core.model_fixtures import make_model_config @@ -185,6 +185,7 @@ def task_state(self): @pytest.fixture def pipeline( self, + app_records, application_generate_entity, queue_manager, conversation, @@ -194,6 +195,7 @@ def pipeline( ): """Create an EasyUIBasedGenerateTaskPipeline instance with mocked dependencies.""" pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=application_generate_entity, queue_manager=queue_manager, conversation=conversation, @@ -309,9 +311,7 @@ def test_message_end_event(self, pipeline, mock_message_cycle_manager, task_stat # Assert assert len(responses) == 1 assert task_state.llm_result == result - session = pipeline._save_message.call_args.kwargs["session"] - assert isinstance(session, Session) - assert committed_sessions == [session] + assert "session" not in pipeline._save_message.call_args.kwargs pipeline._message_end_to_stream_response.assert_called_once() def test_error_event(self, pipeline, committed_sessions): @@ -331,10 +331,8 @@ def test_error_event(self, pipeline, committed_sessions): # Assert assert len(responses) == 1 - session = pipeline.handle_error.call_args.kwargs["session"] - assert isinstance(session, Session) - assert committed_sessions == [session] - pipeline.handle_error.assert_called_once_with(event=error_event, session=session, message_id="test-message-id") + assert len(committed_sessions) == 1 + pipeline.handle_error.assert_called_once_with(event=error_event) pipeline.error_to_stream_response.assert_called_once() def test_ping_event(self, pipeline): @@ -418,10 +416,8 @@ def test_trace_manager_passed_to_save_message(self, pipeline, committed_sessions list(pipeline._process_stream_response(publisher=None, trace_manager=trace_manager)) # Assert - session = pipeline._save_message.call_args.kwargs["session"] - assert isinstance(session, Session) - assert committed_sessions == [session] - pipeline._save_message.assert_called_once_with(session=session, trace_manager=trace_manager) + assert "session" not in pipeline._save_message.call_args.kwargs + pipeline._save_message.assert_called_once_with(trace_manager=trace_manager, preserve_existing_usage=False) def test_multiple_events_sequence(self, pipeline, mock_message_cycle_manager, task_state): """Test handling multiple events in sequence.""" diff --git a/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py b/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py index cb783c03171910..38db7b89dc9318 100644 --- a/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py +++ b/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_based_generate_task_pipeline_core.py @@ -51,7 +51,6 @@ PingStreamResponse, StreamEvent, ) -from core.app.task_pipeline.easy_ui_based_generate_task_pipeline import EasyUIBasedGenerateTaskPipeline from core.base.tts import AppGeneratorTTSPublisher, AudioTrunk from core.ops.entities.trace_entity import TraceTaskName from core.ops.ops_trace_manager import TraceQueueManager @@ -60,12 +59,15 @@ from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, TextPromptMessageContent from models.enums import ConversationFromSource, CreatorUserRole from models.model import AppMode, Conversation, Message, MessageAgentThought, MessageFile, UploadFile -from tests.unit_tests.model_factories import make_upload_file +from services.app.generation.adapters.message_pipeline import EasyUIBasedGenerateTaskPipeline +from services.errors.message import MessageNotExistsError +from tests.unit_tests.model_factories import make_app, make_upload_file class _DummyModelConf: def __init__(self) -> None: self.model = "mock" + self.mode = "chat" class _UnknownQueueEvent: @@ -283,6 +285,7 @@ def _make_entity(entity_cls, app_mode: AppMode): def _make_pipeline( + app_records, entity_cls: type[ChatAppGenerateEntity] | type[CompletionAppGenerateEntity] = ChatAppGenerateEntity, app_mode: AppMode = AppMode.CHAT, *, @@ -290,6 +293,7 @@ def _make_pipeline( ) -> tuple[EasyUIBasedGenerateTaskPipeline, _FakeQueueManager]: queue_manager = _FakeQueueManager() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(entity_cls, app_mode), queue_manager=queue_manager, conversation=_make_conversation(app_mode), @@ -311,11 +315,12 @@ def _set_method(obj: object, name: str, value: object) -> None: class TestEasyUiBasedGenerateTaskPipeline: - def test_to_blocking_response_chat(self): + def test_to_blocking_response_chat(self, app_records): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -331,11 +336,12 @@ def _gen(): assert response.data.answer == "answer" - def test_to_blocking_response_completion(self): + def test_to_blocking_response_completion(self, app_records): conversation = _make_conversation(AppMode.COMPLETION) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(CompletionAppGenerateEntity, AppMode.COMPLETION), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -351,11 +357,12 @@ def _gen(): assert response.data.answer == "answer" - def test_listen_audio_msg_returns_none_when_no_publisher(self): + def test_listen_audio_msg_returns_none_when_no_publisher(self, app_records): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -365,11 +372,12 @@ def test_listen_audio_msg_returns_none_when_no_publisher(self): assert pipeline._listen_audio_msg(publisher=None, task_id="task") is None - def test_process_stream_response_handles_chunks_and_end(self, monkeypatch: pytest.MonkeyPatch): + def test_process_stream_response_handles_chunks_and_end(self, app_records, monkeypatch: pytest.MonkeyPatch): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -414,24 +422,6 @@ def _message_end() -> MessageEndStreamResponse: _set_method(pipeline, "_message_end_to_stream_response", _message_end) _set_method(pipeline, "_save_message", lambda **kwargs: None) - class _Session: - def __init__[**P](self, *args: P.args, **kwargs: P.kwargs): - pass - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def commit(self): - return None - - monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.session_factory.create_session", - lambda: _Session(), - ) - responses = list(pipeline._process_stream_response(publisher=None)) message_response = next(item for item in responses if isinstance(item, MessageStreamResponse)) @@ -442,11 +432,12 @@ def commit(self): assert isinstance(responses[-1], MessageEndStreamResponse) assert pipeline._task_state.llm_result.message.content == "done" - def test_handle_output_moderation_chunk_directs_output(self): + def test_handle_output_moderation_chunk_directs_output(self, app_records): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -470,7 +461,7 @@ def get_final_output(self): assert any(isinstance(event, QueueLLMChunkEvent) for event in events) assert any(isinstance(event, QueueStopEvent) for event in events) - def test_handle_stop_updates_usage(self, monkeypatch: pytest.MonkeyPatch): + def test_handle_stop_updates_usage(self, app_records, monkeypatch: pytest.MonkeyPatch): conversation = _make_conversation(AppMode.CHAT) message = _make_message() @@ -509,6 +500,7 @@ def __init__(self) -> None: ) pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=application_generate_entity, queue_manager=_FakeQueueManager(), conversation=conversation, @@ -529,7 +521,7 @@ def get_llm_num_tokens(self, messages): return 10 if len(calls) == 1 else 5 monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.ModelInstance", + "services.app.generation.adapters.message_pipeline.ModelInstance", _FakeModelInstance, ) @@ -538,11 +530,12 @@ def get_llm_num_tokens(self, messages): assert pipeline._task_state.llm_result.usage.prompt_tokens == 10 assert pipeline._task_state.llm_result.usage.completion_tokens == 5 - def test_record_files_builds_file_payloads(self, monkeypatch: pytest.MonkeyPatch): + def test_record_files_builds_file_payloads(self, app_records, monkeypatch: pytest.MonkeyPatch): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -581,31 +574,6 @@ def test_record_files_builds_file_payloads(self, monkeypatch: pytest.MonkeyPatch ) ] - class _Result: - def __init__(self, items): - self._items = items - - def all(self): - return self._items - - class _Session: - def __init__[**P](self, *args: P.args, **kwargs: P.kwargs): - self.calls = 0 - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def scalars(self, *args, **kwargs): - self.calls += 1 - return _Result(message_files if self.calls == 1 else upload_files) - - monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.session_factory.create_session", - lambda: _Session(), - ) monkeypatch.setattr( "core.app.task_pipeline.message_file_utils.file_helpers.get_signed_file_url", lambda **kwargs: "signed-url", @@ -615,17 +583,21 @@ def scalars(self, *args, **kwargs): lambda **kwargs: "signed-tool", ) + monkeypatch.setattr( + app_records, "message_files", lambda identity: (message_files, {file.id: file for file in upload_files}) + ) response = pipeline._message_end_to_stream_response() files = response.files assert files assert len(files) == 3 - def test_process_stream_response_handles_annotation_and_error(self, monkeypatch: pytest.MonkeyPatch): + def test_process_stream_response_handles_annotation_and_error(self, app_records, monkeypatch: pytest.MonkeyPatch): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -666,7 +638,7 @@ def _agent_message_response(answer: str, message_id: str) -> AgentMessageStreamR _set_method( pipeline._message_cycle_manager, "handle_annotation_reply", - lambda event, session: _AnnotationReply(content="annotated"), + lambda event: _AnnotationReply(content="annotated"), ) _set_method(pipeline, "_agent_thought_to_stream_response", _agent_thought_response) _set_method(pipeline._message_cycle_manager, "message_file_to_stream_response", _file_response) @@ -674,24 +646,6 @@ def _agent_message_response(answer: str, message_id: str) -> AgentMessageStreamR _set_method(pipeline, "handle_error", lambda **kwargs: ValueError("boom")) _set_method(pipeline, "error_to_stream_response", lambda err: ErrorStreamResponse(task_id="task", err=err)) - class _Session: - def __init__[**P](self, *args: P.args, **kwargs: P.kwargs): - pass - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def commit(self): - return None - - monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.session_factory.create_session", - lambda: _Session(), - ) - responses = list(pipeline._process_stream_response(publisher=None)) assert any(isinstance(response, AgentThoughtStreamResponse) for response in responses) @@ -702,13 +656,14 @@ def commit(self): assert isinstance(responses[-1].err, ValueError) assert pipeline._task_state.llm_result.message.content == "annotated" - def test_process_stream_response_error_event_adds_trace_task(self, monkeypatch: pytest.MonkeyPatch): + def test_process_stream_response_error_event_adds_trace_task(self, app_records, monkeypatch: pytest.MonkeyPatch): conversation = _make_conversation(AppMode.CHAT) message = _make_message() application_generate_entity = _make_entity(ChatAppGenerateEntity, AppMode.CHAT) application_generate_entity.extras = {"trace_session_id": "session-1"} pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=application_generate_entity, queue_manager=_FakeQueueManager(), conversation=conversation, @@ -722,21 +677,6 @@ def test_process_stream_response_error_event_adds_trace_task(self, monkeypatch: trace_manager_double = _TraceManagerDouble() trace_manager = cast(TraceQueueManager, trace_manager_double) - class _Session: - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def commit(self): - return None - - monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.session_factory.create_session", - lambda: _Session(), - ) - responses = list(pipeline._process_stream_response(publisher=None, trace_manager=trace_manager)) assert len(responses) == 1 @@ -748,11 +688,12 @@ def commit(self): assert trace_task.message_id == "msg" assert trace_task.kwargs["trace_session_id"] == "session-1" - def test_agent_thought_to_stream_response_returns_payload(self, monkeypatch: pytest.MonkeyPatch): + def test_agent_thought_to_stream_response_returns_payload(self, app_records, monkeypatch: pytest.MonkeyPatch): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -762,35 +703,21 @@ def test_agent_thought_to_stream_response_returns_payload(self, monkeypatch: pyt agent_thought = _agent_thought() - class _Session: - def __init__[**P](self, *args: P.args, **kwargs: P.kwargs): - pass - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def scalar(self, *args, **kwargs): - return agent_thought - - monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.session_factory.create_session", - lambda: _Session(), - ) - + monkeypatch.setattr(app_records, "agent_thought", lambda identity, thought_id: agent_thought) response = pipeline._agent_thought_to_stream_response(QueueAgentThoughtEvent(agent_thought_id="thought")) assert response is not None assert response.id == "thought" assert response.thought == "t" - def test_agent_thought_to_stream_response_normalizes_null_display_fields(self, monkeypatch: pytest.MonkeyPatch): + def test_agent_thought_to_stream_response_normalizes_null_display_fields( + self, app_records, monkeypatch: pytest.MonkeyPatch + ): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -806,23 +733,7 @@ def test_agent_thought_to_stream_response_normalizes_null_display_fields(self, m agent_thought.tool_input = None agent_thought.message_files = None - class _Session: - def __init__(self, *args, **kwargs): - pass - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def scalar(self, *args, **kwargs): - return agent_thought - - monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.session_factory.create_session", - lambda: _Session(), - ) + monkeypatch.setattr(app_records, "agent_thought", lambda identity, thought_id: agent_thought) response = pipeline._agent_thought_to_stream_response(QueueAgentThoughtEvent(agent_thought_id="thought")) assert response is not None @@ -832,10 +743,11 @@ def scalar(self, *args, **kwargs): assert response.tool_input == "" assert response.model_dump(mode="json")["message_files"] == [] - def test_process_routes_to_stream_and_starts_conversation_name_generation(self): + def test_process_routes_to_stream_and_starts_conversation_name_generation(self, app_records): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -857,10 +769,11 @@ def test_process_routes_to_stream_and_starts_conversation_name_generation(self): conversation_id="conv", query="hello", message_id="msg" ) - def test_process_routes_to_blocking_for_completion_mode(self): + def test_process_routes_to_blocking_for_completion_mode(self, app_records): conversation = _make_conversation(AppMode.COMPLETION) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(CompletionAppGenerateEntity, AppMode.COMPLETION), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -880,10 +793,11 @@ def test_process_routes_to_blocking_for_completion_mode(self): assert result == "blocking" pipeline._message_cycle_manager.generate_conversation_name.assert_not_called() - def test_to_blocking_response_raises_error_stream_exception(self): + def test_to_blocking_response_raises_error_stream_exception(self, app_records): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -897,10 +811,11 @@ def _gen(): with pytest.raises(ValueError, match="stream error"): pipeline._to_blocking_response(_gen()) - def test_to_blocking_response_raises_when_generator_ends_without_message_end(self): + def test_to_blocking_response_raises_when_generator_ends_without_message_end(self, app_records): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -914,10 +829,11 @@ def _gen(): with pytest.raises(RuntimeError, match="queue listening stopped unexpectedly"): pipeline._to_blocking_response(_gen()) - def test_to_stream_response_wraps_completion_stream_events(self): + def test_to_stream_response_wraps_completion_stream_events(self, app_records): conversation = _make_conversation(AppMode.COMPLETION) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(CompletionAppGenerateEntity, AppMode.COMPLETION), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -933,10 +849,11 @@ def _gen(): assert isinstance(response, CompletionAppStreamResponse) assert response.message_id == "msg" - def test_to_stream_response_wraps_chat_stream_events(self): + def test_to_stream_response_wraps_chat_stream_events(self, app_records): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -952,10 +869,11 @@ def _gen(): assert isinstance(response, ChatbotAppStreamResponse) assert response.conversation_id == "conv" - def test_listen_audio_msg_returns_audio_response_for_non_finish_audio(self): + def test_listen_audio_msg_returns_audio_response_for_non_finish_audio(self, app_records): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -972,10 +890,11 @@ def test_listen_audio_msg_returns_audio_response_for_non_finish_audio(self): assert isinstance(response, MessageAudioStreamResponse) assert response.audio == "abc" - def test_listen_audio_msg_returns_none_for_finish_audio(self): + def test_listen_audio_msg_returns_none_for_finish_audio(self, app_records): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -989,10 +908,11 @@ def test_listen_audio_msg_returns_none_for_finish_audio(self): assert pipeline._listen_audio_msg(publisher=publisher, task_id="task") is None - def test_wrapper_process_stream_response_without_tts_publisher(self): + def test_wrapper_process_stream_response_without_tts_publisher(self, app_records): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -1006,7 +926,7 @@ def test_wrapper_process_stream_response_without_tts_publisher(self): assert responses == [payload] - def test_wrapper_process_stream_response_with_tts_publisher(self, monkeypatch: pytest.MonkeyPatch): + def test_wrapper_process_stream_response_with_tts_publisher(self, app_records, monkeypatch: pytest.MonkeyPatch): conversation = _make_conversation(AppMode.CHAT) message = _make_message() entity = _make_entity(ChatAppGenerateEntity, AppMode.CHAT) @@ -1014,6 +934,7 @@ def test_wrapper_process_stream_response_with_tts_publisher(self, monkeypatch: p "text_to_speech": {"autoPlay": "enabled", "enabled": True, "voice": "v", "language": "en"} } pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=entity, queue_manager=_FakeQueueManager(), conversation=conversation, @@ -1038,7 +959,7 @@ def cancel(self): _set_method(pipeline, "_listen_audio_msg", lambda publisher, task_id: next(audio_calls)) _set_method(pipeline, "_process_stream_response", lambda publisher, trace_manager: iter([payload])) monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.AppGeneratorTTSPublisher", + "services.app.generation.adapters.message_pipeline.AppGeneratorTTSPublisher", lambda tenant_id, voice, language, app_type: _Publisher(), ) @@ -1049,7 +970,9 @@ def cancel(self): assert isinstance(responses[-1], MessageAudioEndStreamResponse) assert "audio_type" not in responses[-1].model_dump() - def test_wrapper_process_stream_response_waits_for_the_audio_terminal(self, monkeypatch: pytest.MonkeyPatch): + def test_wrapper_process_stream_response_waits_for_the_audio_terminal( + self, app_records, monkeypatch: pytest.MonkeyPatch + ): conversation = _make_conversation(AppMode.CHAT) message = _make_message() entity = _make_entity(ChatAppGenerateEntity, AppMode.CHAT) @@ -1057,6 +980,7 @@ def test_wrapper_process_stream_response_waits_for_the_audio_terminal(self, monk "text_to_speech": {"autoPlay": "enabled", "enabled": True, "voice": "v", "language": "en"} } pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=entity, queue_manager=_FakeQueueManager(), conversation=conversation, @@ -1080,7 +1004,7 @@ def cancel(self): _set_method(pipeline, "_process_stream_response", lambda publisher, trace_manager: iter([])) monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.AppGeneratorTTSPublisher", + "services.app.generation.adapters.message_pipeline.AppGeneratorTTSPublisher", lambda tenant_id, voice, language, app_type: _Publisher(), ) responses = list(pipeline._wrapper_process_stream_response()) @@ -1089,7 +1013,7 @@ def cancel(self): assert isinstance(responses[-1], MessageAudioEndStreamResponse) def test_wrapper_process_stream_response_ends_audio_before_reporting_tts_error( - self, monkeypatch: pytest.MonkeyPatch + self, app_records, monkeypatch: pytest.MonkeyPatch ): conversation = _make_conversation(AppMode.CHAT) message = _make_message() @@ -1098,6 +1022,7 @@ def test_wrapper_process_stream_response_ends_audio_before_reporting_tts_error( "text_to_speech": {"autoPlay": "enabled", "enabled": True, "voice": "v", "language": "en"} } pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=entity, queue_manager=_FakeQueueManager(), conversation=conversation, @@ -1119,7 +1044,7 @@ def cancel(self): _set_method(pipeline, "_process_stream_response", lambda publisher, trace_manager: iter([])) monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.AppGeneratorTTSPublisher", + "services.app.generation.adapters.message_pipeline.AppGeneratorTTSPublisher", lambda tenant_id, voice, language, app_type: _Publisher(), ) @@ -1129,7 +1054,9 @@ def cancel(self): assert isinstance(responses[-1], ErrorStreamResponse) assert responses[-1].err is error - def test_wrapper_process_stream_response_ends_audio_before_a_main_error(self, monkeypatch: pytest.MonkeyPatch): + def test_wrapper_process_stream_response_ends_audio_before_a_main_error( + self, app_records, monkeypatch: pytest.MonkeyPatch + ): conversation = _make_conversation(AppMode.CHAT) message = _make_message() entity = _make_entity(ChatAppGenerateEntity, AppMode.CHAT) @@ -1137,6 +1064,7 @@ def test_wrapper_process_stream_response_ends_audio_before_a_main_error(self, mo "text_to_speech": {"autoPlay": "enabled", "enabled": True, "voice": "v", "language": "en"} } pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=entity, queue_manager=_FakeQueueManager(), conversation=conversation, @@ -1148,7 +1076,7 @@ def test_wrapper_process_stream_response_ends_audio_before_a_main_error(self, mo error = ErrorStreamResponse(task_id="task", err=RuntimeError("generation failed")) _set_method(pipeline, "_process_stream_response", lambda publisher, trace_manager: iter([error])) monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.AppGeneratorTTSPublisher", + "services.app.generation.adapters.message_pipeline.AppGeneratorTTSPublisher", lambda tenant_id, voice, language, app_type: publisher, ) @@ -1159,10 +1087,13 @@ def test_wrapper_process_stream_response_ends_audio_before_a_main_error(self, mo assert publisher.cancel.called publisher.publish.assert_not_called() - def test_process_stream_response_handles_stop_event_and_output_replacement(self, monkeypatch: pytest.MonkeyPatch): + def test_process_stream_response_handles_stop_event_and_output_replacement( + self, app_records, monkeypatch: pytest.MonkeyPatch + ): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -1185,24 +1116,6 @@ def test_process_stream_response_handles_stop_event_and_output_replacement(self, lambda: MessageEndStreamResponse(task_id="task", id="msg"), ) - class _Session: - def __init__[**P](self, *args: P.args, **kwargs: P.kwargs): - pass - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def commit(self): - return None - - monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.session_factory.create_session", - lambda: _Session(), - ) - responses = list(pipeline._process_stream_response(publisher=None)) assert isinstance(responses[0], MessageReplaceStreamResponse) @@ -1210,10 +1123,11 @@ def commit(self): assert isinstance(responses[1], MessageEndStreamResponse) pipeline._handle_stop.assert_called_once() - def test_process_stream_response_handles_retriever_unknown_and_empty_chunk(self): + def test_process_stream_response_handles_retriever_unknown_and_empty_chunk(self, app_records): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -1246,10 +1160,11 @@ def _handle_retriever_resources(event): assert responses == [] assert handled["retriever"] == 1 - def test_process_stream_response_skips_when_output_moderation_directs_chunk(self): + def test_process_stream_response_skips_when_output_moderation_directs_chunk(self, app_records): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -1268,10 +1183,11 @@ def test_process_stream_response_skips_when_output_moderation_directs_chunk(self assert responses == [] - def test_process_stream_response_ignores_unsupported_chunk_content_types(self): + def test_process_stream_response_ignores_unsupported_chunk_content_types(self, app_records): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -1294,10 +1210,11 @@ def test_process_stream_response_ignores_unsupported_chunk_content_types(self): assert responses[0].answer == "ok" assert pipeline._task_state.llm_result.message.content == "ok" - def test_process_stream_response_skips_none_chunk_content(self): + def test_process_stream_response_skips_none_chunk_content(self, app_records): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -1318,10 +1235,11 @@ def test_process_stream_response_skips_none_chunk_content(self): pipeline._message_cycle_manager.message_to_stream_response.assert_not_called() assert pipeline._task_state.llm_result.message.content == "" - def test_process_stream_response_reaches_post_loop_branch_with_thread_reference(self): + def test_process_stream_response_reaches_post_loop_branch_with_thread_reference(self, app_records): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -1334,13 +1252,14 @@ def test_process_stream_response_reaches_post_loop_branch_with_thread_reference( assert list(pipeline._process_stream_response(publisher=None)) == [] def test_save_message_persists_fields_and_emits_trace( - self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session + self, app_records, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): conversation = _make_conversation(AppMode.CHAT) message = _make_message() application_generate_entity = _make_entity(ChatAppGenerateEntity, AppMode.CHAT) application_generate_entity.extras = {"trace_session_id": "session-1"} pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=application_generate_entity, queue_manager=_FakeQueueManager(), conversation=conversation, @@ -1359,34 +1278,33 @@ def test_save_message_persists_fields_and_emits_trace( conversation_obj = _make_conversation(AppMode.CHAT) session = sqlite_session session.add_all([conversation_obj, message_obj]) - session.flush() + session.commit() trace_manager_double = _TraceManagerDouble() trace_manager = cast(TraceQueueManager, trace_manager_double) sent_payloads: list[tuple[tuple[object, ...], dict[str, object]]] = [] monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.PromptMessageUtil.prompt_messages_to_prompt_for_saving", - lambda mode, prompt_messages: "serialized-prompt", + "services.app.generation.adapters.message_pipeline.PromptMessageUtil.prompt_messages_to_prompt_for_saving", + lambda mode, prompt_messages: [{"role": "user", "text": "serialized-prompt"}], ) monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.PromptTemplateParser.remove_template_variables", + "services.app.generation.adapters.message_pipeline.PromptTemplateParser.remove_template_variables", lambda text: text.replace("{{name}}", "").strip(), ) monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.naive_utc_now", + "repositories.app.generation_repository.naive_utc_now", lambda: datetime(2024, 1, 1, tzinfo=UTC), ) + monkeypatch.setattr("services.app.generation.adapters.message_pipeline.time.perf_counter", lambda: 15.0) monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.time.perf_counter", lambda: 15.0 - ) - monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.message_was_created.send", + "services.app.generation.adapters.message_pipeline.message_was_created.send", lambda *args, **kwargs: sent_payloads.append((args, kwargs)), ) - pipeline._save_message(session=session, trace_manager=trace_manager) + pipeline._save_message(trace_manager=trace_manager) - assert message_obj.message == "serialized-prompt" + session.refresh(message_obj) + assert message_obj.message == [{"role": "user", "text": "serialized-prompt"}] assert message_obj.answer == "hello" assert message_obj.provider_response_latency == 5.0 trace_manager_double.add_trace_task.assert_called_once() @@ -1398,9 +1316,10 @@ def test_save_message_persists_fields_and_emits_trace( assert len(sent_payloads) == 1 def test_save_stopped_message_preserves_backend_reported_usage( - self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session + self, app_records, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=_make_conversation(AppMode.CHAT), @@ -1416,18 +1335,19 @@ def test_save_stopped_message_preserves_backend_reported_usage( message.total_price = Decimal("0.21") message.message_metadata = json.dumps({"usage": {"total_tokens": 21}}) sqlite_session.add_all([_make_conversation(AppMode.CHAT), message]) - sqlite_session.flush() + sqlite_session.commit() monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.PromptMessageUtil.prompt_messages_to_prompt_for_saving", - lambda _mode, _prompt_messages: "", + "services.app.generation.adapters.message_pipeline.PromptMessageUtil.prompt_messages_to_prompt_for_saving", + lambda _mode, _prompt_messages: [], ) monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.message_was_created.send", + "services.app.generation.adapters.message_pipeline.message_was_created.send", lambda *_args, **_kwargs: None, ) - pipeline._save_message(session=sqlite_session, preserve_existing_usage=True) + pipeline._save_message(preserve_existing_usage=True) + sqlite_session.refresh(message) assert message.message_tokens == 13 assert message.answer_tokens == 8 assert message.total_price == Decimal("0.21") @@ -1437,11 +1357,13 @@ def test_save_stopped_message_preserves_backend_reported_usage( @pytest.mark.parametrize("metadata", ["{", "[]"]) def test_save_stopped_message_recovers_invalid_existing_metadata( self, + app_records, metadata: str, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, ) -> None: pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=_make_conversation(AppMode.CHAT), @@ -1457,26 +1379,28 @@ def test_save_stopped_message_recovers_invalid_existing_metadata( message.total_price = Decimal("0.21") message.message_metadata = metadata sqlite_session.add_all([_make_conversation(AppMode.CHAT), message]) - sqlite_session.flush() + sqlite_session.commit() monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.PromptMessageUtil.prompt_messages_to_prompt_for_saving", - lambda _mode, _prompt_messages: "", + "services.app.generation.adapters.message_pipeline.PromptMessageUtil.prompt_messages_to_prompt_for_saving", + lambda _mode, _prompt_messages: [], ) monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.message_was_created.send", + "services.app.generation.adapters.message_pipeline.message_was_created.send", lambda *_args, **_kwargs: None, ) - pipeline._save_message(session=sqlite_session, preserve_existing_usage=True) + pipeline._save_message(preserve_existing_usage=True) + sqlite_session.refresh(message) assert message.message_tokens == 13 assert message.answer_tokens == 8 assert message.total_price == Decimal("0.21") - def test_save_message_raises_when_message_not_found(self, sqlite_session: Session): + def test_save_message_raises_when_message_not_found(self, app_records, sqlite_session: Session): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -1485,13 +1409,14 @@ def test_save_message_raises_when_message_not_found(self, sqlite_session: Sessio ) session = sqlite_session - with pytest.raises(ValueError, match="message msg not found"): - pipeline._save_message(session=session) + with pytest.raises(MessageNotExistsError): + pipeline._save_message() - def test_save_message_raises_when_conversation_not_found(self, sqlite_session: Session): + def test_save_message_raises_when_conversation_not_found(self, app_records, sqlite_session: Session): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -1500,15 +1425,16 @@ def test_save_message_raises_when_conversation_not_found(self, sqlite_session: S ) session = sqlite_session session.add(_make_message()) - session.flush() + session.commit() - with pytest.raises(ValueError, match="Conversation conv not found"): - pipeline._save_message(session=session) + with pytest.raises(MessageNotExistsError): + pipeline._save_message() - def test_message_end_to_stream_response_includes_usage_metadata(self, monkeypatch: pytest.MonkeyPatch): + def test_message_end_to_stream_response_includes_usage_metadata(self, app_records, monkeypatch: pytest.MonkeyPatch): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -1517,38 +1443,19 @@ def test_message_end_to_stream_response_includes_usage_metadata(self, monkeypatc ) pipeline._task_state.llm_result.usage = LLMUsage.from_metadata({"prompt_tokens": 1, "completion_tokens": 2}) - class _Result: - def all(self): - return [] - - class _Session: - def __init__[**P](self, *args: P.args, **kwargs: P.kwargs): - pass - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def scalars(self, *args, **kwargs): - return _Result() - - monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.session_factory.create_session", - lambda: _Session(), - ) - response = pipeline._message_end_to_stream_response() assert response.id == "msg" usage_metadata = cast(dict[str, object], response.metadata["usage"]) assert usage_metadata["prompt_tokens"] == 1 - def test_record_files_returns_empty_list_when_message_has_no_files(self, monkeypatch: pytest.MonkeyPatch): + def test_record_files_returns_empty_list_when_message_has_no_files( + self, app_records, monkeypatch: pytest.MonkeyPatch + ): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -1556,36 +1463,17 @@ def test_record_files_returns_empty_list_when_message_has_no_files(self, monkeyp stream=False, ) - class _Result: - def all(self): - return [] - - class _Session: - def __init__[**P](self, *args: P.args, **kwargs: P.kwargs): - pass - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def scalars(self, *args, **kwargs): - return _Result() - - monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.session_factory.create_session", - lambda: _Session(), - ) - response = pipeline._message_end_to_stream_response() assert response.files == [] - def test_record_files_handles_local_fallback_and_tool_url_variants(self, monkeypatch: pytest.MonkeyPatch): + def test_record_files_handles_local_fallback_and_tool_url_variants( + self, app_records, monkeypatch: pytest.MonkeyPatch + ): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -1616,31 +1504,6 @@ def test_record_files_handles_local_fallback_and_tool_url_variants(self, monkeyp ), ] - class _Result: - def __init__(self, items): - self._items = items - - def all(self): - return self._items - - class _Session: - def __init__[**P](self, *args: P.args, **kwargs: P.kwargs): - self.calls = 0 - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def scalars(self, *args, **kwargs): - self.calls += 1 - return _Result(message_files if self.calls == 1 else []) - - monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.session_factory.create_session", - lambda: _Session(), - ) monkeypatch.setattr( "core.app.task_pipeline.message_file_utils.file_helpers.get_signed_file_url", lambda **kwargs: "local-fallback-signed", @@ -1650,6 +1513,7 @@ def scalars(self, *args, **kwargs): lambda **kwargs: "tool-signed", ) + monkeypatch.setattr(app_records, "message_files", lambda identity: (message_files, {})) response = pipeline._message_end_to_stream_response() files = response.files @@ -1658,10 +1522,11 @@ def scalars(self, *args, **kwargs): assert files[1]["filename"] == "file.txt" assert files[2]["extension"] == ".bin" - def test_agent_message_to_stream_response_builds_payload(self): + def test_agent_message_to_stream_response_builds_payload(self, app_records): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -1674,10 +1539,13 @@ def test_agent_message_to_stream_response_builds_payload(self): assert response.id == "msg" assert response.answer == "hello" - def test_agent_thought_to_stream_response_returns_none_when_not_found(self, monkeypatch: pytest.MonkeyPatch): + def test_agent_thought_to_stream_response_returns_none_when_not_found( + self, app_records, monkeypatch: pytest.MonkeyPatch + ): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -1685,32 +1553,15 @@ def test_agent_thought_to_stream_response_returns_none_when_not_found(self, monk stream=True, ) - class _Session: - def __init__[**P](self, *args: P.args, **kwargs: P.kwargs): - pass - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, tb): - return False - - def scalar(self, *args, **kwargs): - return None - - monkeypatch.setattr( - "core.app.task_pipeline.easy_ui_based_generate_task_pipeline.session_factory.create_session", - lambda: _Session(), - ) - response = pipeline._agent_thought_to_stream_response(QueueAgentThoughtEvent(agent_thought_id="missing")) assert response is None - def test_handle_output_moderation_chunk_appends_token_when_not_directing(self): + def test_handle_output_moderation_chunk_appends_token_when_not_directing(self, app_records): conversation = _make_conversation(AppMode.CHAT) message = _make_message() pipeline = EasyUIBasedGenerateTaskPipeline( + records=app_records, application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT), queue_manager=_FakeQueueManager(), conversation=conversation, @@ -1732,3 +1583,9 @@ def append_new_token(self, text): assert result is False assert appended_tokens == ["next-token"] + + +@pytest.fixture(autouse=True) +def generation_app(sqlite_session): + sqlite_session.add(make_app(app_id="app", tenant_id="tenant")) + sqlite_session.commit() diff --git a/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_message_end_files.py b/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_message_end_files.py index c353fca675ffac..44921ea3edcd1c 100644 --- a/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_message_end_files.py +++ b/api/tests/unit_tests/core/app/task_pipeline/test_easy_ui_message_end_files.py @@ -9,17 +9,18 @@ from unittest.mock import Mock, patch import pytest -from sqlalchemy.engine import Engine -from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.orm import Session from core.app.entities.task_entities import MessageEndStreamResponse -from core.app.task_pipeline.easy_ui_based_generate_task_pipeline import EasyUIBasedGenerateTaskPipeline from extensions.storage.storage_type import StorageType from graphon.file import FileTransferMethod, FileType from models.enums import CreatorUserRole -from models.model import MessageFile, UploadFile +from models.model import App, Conversation, Message, MessageFile, UploadFile +from services.app.generation.adapters.message_pipeline import EasyUIBasedGenerateTaskPipeline +from services.app.generation.ports import MessageIdentity +from tests.unit_tests.model_factories import make_app, make_conversation, make_message -SQLITE_MODELS = (MessageFile, UploadFile) +SQLITE_MODELS = (App, Conversation, Message, MessageFile, UploadFile) pytestmark = [ pytest.mark.usefixtures("sqlite_session"), pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True), @@ -29,19 +30,32 @@ class TestMessageEndStreamResponseFiles: """Verify message-end file payloads from actual ORM query results.""" - @pytest.fixture(autouse=True) - def bind_sqlite_engine(self, sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> None: - """Bind sessions opened by the pipeline to the per-test SQLite engine.""" - - sqlite_session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False) - monkeypatch.setattr("core.db.session_factory._session_maker", sqlite_session_maker) - @pytest.fixture - def mock_pipeline(self) -> Mock: + def mock_pipeline(self, app_records, sqlite_session: Session) -> Mock: """Create the minimal pipeline collaborator required by the method under test.""" pipeline = Mock(spec=EasyUIBasedGenerateTaskPipeline) pipeline._message_id = str(uuid.uuid4()) + pipeline._records = app_records + pipeline._identity = MessageIdentity("tenant-1", "app-1", "conversation-1", pipeline._message_id) + sqlite_session.add_all( + [ + make_app(), + make_conversation(inputs={}, from_source="api"), + make_message( + message_id=pipeline._message_id, + inputs={}, + query="hello", + message={}, + answer="", + message_unit_price=0, + answer_unit_price=0, + currency="USD", + from_source="api", + ), + ] + ) + sqlite_session.commit() pipeline._task_state = Mock() pipeline._task_state.metadata = Mock() pipeline._task_state.metadata.model_dump = Mock(return_value={"test": "metadata"}) @@ -100,7 +114,7 @@ def upload_file(self, message_file_local: MessageFile) -> UploadFile: """Create upload metadata matching the local message-file reference.""" upload = UploadFile( - tenant_id=str(uuid.uuid4()), + tenant_id="tenant-1", storage_type=StorageType.LOCAL, key="uploads/test_image.png", name="test_image.png", diff --git a/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py b/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py index 283e5159fab385..92ace42af0ddaf 100644 --- a/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py +++ b/api/tests/unit_tests/core/app/task_pipeline/test_message_cycle_manager_optimization.py @@ -1,50 +1,28 @@ """Unit tests for the message cycle manager optimization.""" import logging -from collections.abc import Iterator -from dataclasses import dataclass from types import SimpleNamespace from unittest.mock import Mock, patch import pytest from flask import Flask, current_app from sqlalchemy import Engine, event, select -from sqlalchemy.orm import Session, sessionmaker +from sqlalchemy.orm import Session from core.app.entities.queue_entities import QueueAnnotationReplyEvent, QueueRetrieverResourcesEvent from core.app.entities.task_entities import MessageStreamResponse, StreamEvent, TaskStateMetadata, WorkflowTaskState -from core.app.task_pipeline import message_cycle_manager as message_cycle_manager_module -from core.app.task_pipeline.message_cycle_manager import MessageCycleManager from core.rag.entities import RetrievalSourceMetadata from graphon.file import FileTransferMethod, FileType -from models import model as model_module from models.account import Account -from models.base import TypeBase from models.enums import ConversationFromSource, CreatorUserRole, MessageFileBelongsTo -from models.model import App, Conversation, MessageAnnotation, MessageFile -from tests.unit_tests.model_factories import make_app, make_conversation - - -@dataclass(frozen=True) -class _SQLiteDb: - engine: Engine - session: Session +from models.model import App, Conversation, Message, MessageAnnotation, MessageFile +from services.app.generation.adapters.message_cycle import MessageCycleManager +from tests.unit_tests.model_factories import make_app, make_conversation, make_message @pytest.fixture -def cycle_db(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Session]: - """Bind request-owned and cycle-manager-owned sessions to isolated SQLite.""" - TypeBase.metadata.create_all( - sqlite_engine, - tables=[App.__table__, Conversation.__table__, MessageFile.__table__], - ) - owned_session_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False) - with owned_session_factory() as request_session: - sqlite_db = _SQLiteDb(engine=sqlite_engine, session=request_session) - monkeypatch.setattr(message_cycle_manager_module, "db", sqlite_db) - monkeypatch.setattr(model_module, "db", sqlite_db) - monkeypatch.setattr(message_cycle_manager_module.session_factory, "create_session", owned_session_factory) - yield request_session +def cycle_db(sqlite_session: Session) -> Session: + return sqlite_session def _app(*, app_id: str = "app-id", tenant_id: str = "tenant-1") -> App: @@ -63,6 +41,7 @@ def _conversation(*, conversation_id: str = "conv-1", app_id: str = "app-id") -> def _message_file( + session: Session, *, file_id: str = "file-1", message_id: str = "test-message-id", @@ -70,6 +49,23 @@ def _message_file( url: str | None = "http://example.com/image.png", file_type: FileType = FileType.IMAGE, ) -> MessageFile: + if session.get(App, "app-id") is None: + session.add(_app()) + if session.get(Message, message_id) is None: + session.add( + make_message( + message_id=message_id, + app_id="app-id", + inputs={}, + query="", + message={}, + answer="", + message_unit_price=0, + answer_unit_price=0, + currency="USD", + from_source=ConversationFromSource.API, + ) + ) message_file = MessageFile( message_id=message_id, type=file_type, @@ -91,13 +87,17 @@ def mock_application_generate_entity(self): """Create a mock application generate entity.""" entity = Mock() entity.task_id = "test-task-id" + entity.app_config.tenant_id = "tenant-1" + entity.app_config.app_id = "app-id" return entity @pytest.fixture - def message_cycle_manager(self, mock_application_generate_entity): + def message_cycle_manager(self, app_records, mock_application_generate_entity): """Create a message cycle manager instance.""" task_state = Mock() - return MessageCycleManager(application_generate_entity=mock_application_generate_entity, task_state=task_state) + return MessageCycleManager( + records=app_records, application_generate_entity=mock_application_generate_entity, task_state=task_state + ) def test_get_message_event_type_with_assistant_file(self, message_cycle_manager, cycle_db: Session): """Test get_message_event_type returns MESSAGE_FILE when message has assistant-generated files. @@ -105,7 +105,11 @@ def test_get_message_event_type_with_assistant_file(self, message_cycle_manager, This ensures that AI-generated images (belongs_to='assistant') trigger the MESSAGE_FILE event, allowing the frontend to properly display generated image files with url field. """ - cycle_db.add(_message_file()) + cycle_db.add( + _message_file( + cycle_db, + ) + ) cycle_db.commit() with current_app.app_context(): @@ -122,7 +126,7 @@ def test_get_message_event_type_with_user_file(self, message_cycle_manager, cycl resulting in broken images in the chat UI. The query filters for belongs_to='assistant', so when only user files exist, the database query returns None, resulting in MESSAGE event type. """ - cycle_db.add(_message_file(belongs_to=MessageFileBelongsTo.USER)) + cycle_db.add(_message_file(cycle_db, belongs_to=MessageFileBelongsTo.USER)) cycle_db.commit() with current_app.app_context(): @@ -161,7 +165,11 @@ def record_statement(_conn, _cursor, statement, _parameters, _context, _executem def test_message_to_stream_response_with_precomputed_event_type(self, message_cycle_manager, cycle_db: Session): """MessageCycleManager.message_to_stream_response expects a valid event_type; callers should precompute it.""" - cycle_db.add(_message_file()) + cycle_db.add( + _message_file( + cycle_db, + ) + ) cycle_db.commit() with current_app.app_context(): @@ -251,7 +259,7 @@ def test_generate_conversation_name_returns_none_for_completion(self, message_cy class DummyCompletion: pass - with patch("core.app.task_pipeline.message_cycle_manager.CompletionAppGenerateEntity", DummyCompletion): + with patch("services.app.generation.adapters.message_cycle.CompletionAppGenerateEntity", DummyCompletion): message_cycle_manager._application_generate_entity = DummyCompletion() result = message_cycle_manager.generate_conversation_name(conversation_id="c1", query="hi") @@ -277,10 +285,10 @@ def start(self): with ( patch( - "core.app.task_pipeline.message_cycle_manager.current_app", + "services.app.generation.adapters.message_cycle.current_app", new=SimpleNamespace(_get_current_object=lambda: flask_app), ), - patch("core.app.task_pipeline.message_cycle_manager.Timer", DummyTimer), + patch("services.app.generation.adapters.message_cycle.Timer", DummyTimer), ): thread = message_cycle_manager.generate_conversation_name( conversation_id="conv-1", query="hello", message_id="message-1" @@ -302,7 +310,7 @@ def test_generate_conversation_name_skips_thread_when_auto_generate_disabled(sel message_cycle_manager._application_generate_entity.is_new_conversation = True message_cycle_manager._application_generate_entity.extras = {"auto_generate_conversation_name": False} - with patch("core.app.task_pipeline.message_cycle_manager.Timer") as mock_timer: + with patch("services.app.generation.adapters.message_cycle.Timer") as mock_timer: result = message_cycle_manager.generate_conversation_name(conversation_id="conv-2", query="hello") assert result is None @@ -341,8 +349,8 @@ def test_generate_conversation_name_worker_uses_cached_name( cycle_db.commit() with ( - patch("core.app.task_pipeline.message_cycle_manager.redis_client") as mock_redis, - patch("core.app.task_pipeline.message_cycle_manager.LLMGenerator") as mock_llm_generator, + patch("services.app.generation.adapters.message_cycle.redis_client") as mock_redis, + patch("services.app.generation.adapters.message_cycle.LLMGenerator") as mock_llm_generator, ): mock_redis.get.return_value = b"cached-title" @@ -365,8 +373,8 @@ def test_generate_conversation_name_worker_generates_and_caches_name( cycle_db.commit() with ( - patch("core.app.task_pipeline.message_cycle_manager.redis_client") as mock_redis, - patch("core.app.task_pipeline.message_cycle_manager.LLMGenerator") as mock_llm_generator, + patch("services.app.generation.adapters.message_cycle.redis_client") as mock_redis, + patch("services.app.generation.adapters.message_cycle.LLMGenerator") as mock_llm_generator, ): mock_redis.get.return_value = None mock_llm_generator.generate_conversation_name.return_value = "generated-title" @@ -401,12 +409,12 @@ def test_generate_conversation_name_worker_falls_back_when_generation_fails( long_query = "q" * 60 with ( - patch("core.app.task_pipeline.message_cycle_manager.redis_client") as mock_redis, - patch("core.app.task_pipeline.message_cycle_manager.LLMGenerator") as mock_llm_generator, + patch("services.app.generation.adapters.message_cycle.redis_client") as mock_redis, + patch("services.app.generation.adapters.message_cycle.LLMGenerator") as mock_llm_generator, ): mock_redis.get.return_value = None mock_llm_generator.generate_conversation_name.side_effect = RuntimeError("generation failed") - with caplog.at_level(logging.ERROR, logger="core.app.task_pipeline.message_cycle_manager"): + with caplog.at_level(logging.ERROR, logger="services.app.generation.adapters.message_cycle"): message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", long_query) assert cycle_db.in_transaction() is False @@ -429,6 +437,7 @@ def test_handle_annotation_reply_reads_author_in_one_query( account_id="acct-1", ) annotation.id = "ann-1" + sqlite_session.add(_app()) sqlite_session.add(annotation) if author_name is not None: account = Account(name=author_name, email="author@example.com") @@ -444,7 +453,7 @@ def capture_sql(_connection, _cursor, statement: str, _parameters, _context, _ex event.listen(sqlite_engine, "before_cursor_execute", capture_sql) try: result = message_cycle_manager.handle_annotation_reply( - QueueAnnotationReplyEvent(message_annotation_id="ann-1"), sqlite_session + QueueAnnotationReplyEvent(message_annotation_id="ann-1") ) finally: event.remove(sqlite_engine, "before_cursor_execute", capture_sql) @@ -463,7 +472,7 @@ def test_handle_annotation_reply_returns_none_when_missing(self, message_cycle_m """Return None and keep metadata unchanged when annotation is not found.""" message_cycle_manager._task_state = WorkflowTaskState() result = message_cycle_manager.handle_annotation_reply( - QueueAnnotationReplyEvent(message_annotation_id="missing"), sqlite_session + QueueAnnotationReplyEvent(message_annotation_id="missing") ) assert result is None @@ -502,6 +511,7 @@ def test_message_file_to_stream_response_builds_signed_url(self, message_cycle_m message_cycle_manager._application_generate_entity.task_id = "task-1" cycle_db.add( _message_file( + cycle_db, file_id="file-1", message_id="msg-1", belongs_to=None, @@ -510,7 +520,7 @@ def test_message_file_to_stream_response_builds_signed_url(self, message_cycle_m ) cycle_db.commit() - with patch("core.app.task_pipeline.message_cycle_manager.sign_tool_file") as mock_sign: + with patch("services.app.generation.adapters.message_cycle.sign_tool_file") as mock_sign: mock_sign.return_value = "signed-url" response = message_cycle_manager.message_file_to_stream_response(SimpleNamespace(message_file_id="file-1")) @@ -549,6 +559,7 @@ def test_message_file_to_stream_response_uses_http_url_directly(self, message_cy message_cycle_manager._application_generate_entity.task_id = "task-http" cycle_db.add( _message_file( + cycle_db, file_id="file-http", message_id="msg-http", belongs_to=MessageFileBelongsTo.ASSISTANT, @@ -570,6 +581,7 @@ def test_message_file_to_stream_response_defaults_extension_to_bin_without_dot( message_cycle_manager._application_generate_entity.task_id = "task-bin" cycle_db.add( _message_file( + cycle_db, file_id="file-bin", message_id="msg-bin", belongs_to=MessageFileBelongsTo.ASSISTANT, @@ -579,7 +591,7 @@ def test_message_file_to_stream_response_defaults_extension_to_bin_without_dot( ) cycle_db.commit() - with patch("core.app.task_pipeline.message_cycle_manager.sign_tool_file") as mock_sign: + with patch("services.app.generation.adapters.message_cycle.sign_tool_file") as mock_sign: mock_sign.return_value = "signed-bin-url" response = message_cycle_manager.message_file_to_stream_response( @@ -606,3 +618,59 @@ def test_message_replace_to_stream_response_returns_reason(self, message_cycle_m assert response.answer == "replaced" assert response.reason == "moderation" + + +def test_conversation_name_model_call_holds_no_database_connection( + app, + app_records, + sqlite_session, + sqlite_engine, + monkeypatch, +): + from core.app.app_config.entities import WorkflowUIBasedAppConfig + from core.app.entities.app_invoke_entities import AdvancedChatAppGenerateEntity, InvokeFrom + from models.model import AppMode + from services.app.generation.adapters import message_cycle + + sqlite_session.add_all([_app(), _conversation()]) + sqlite_session.commit() + entity = AdvancedChatAppGenerateEntity( + task_id="task", + app_config=WorkflowUIBasedAppConfig( + tenant_id="tenant-1", + app_id="app-id", + app_mode=AppMode.ADVANCED_CHAT, + workflow_id="workflow", + ), + inputs={}, + query="Hello", + files=[], + user_id="user", + stream=False, + invoke_from=InvokeFrom.WEB_APP, + ) + manager = MessageCycleManager( + records=app_records, application_generate_entity=entity, task_state=WorkflowTaskState() + ) + checked_out = set() + + def checkout(_dbapi, record, _proxy): + checked_out.add(id(record)) + + def checkin(_dbapi, record): + checked_out.discard(id(record)) + + def generate_name(*_args, **_kwargs): + assert checked_out == set() + return "Generated name" + + monkeypatch.setattr(message_cycle.LLMGenerator, "generate_conversation_name", generate_name) + event.listen(sqlite_engine, "checkout", checkout) + event.listen(sqlite_engine, "checkin", checkin) + try: + manager._generate_conversation_name_worker(app, "conv-1", "Hello") + assert checked_out == set() + finally: + event.remove(sqlite_engine, "checkout", checkout) + event.remove(sqlite_engine, "checkin", checkin) + assert sqlite_session.get(Conversation, "conv-1").name == "Generated name" diff --git a/api/tests/unit_tests/core/app/workflow/layers/test_persistence.py b/api/tests/unit_tests/core/app/workflow/layers/test_persistence.py index b7a8754d92c855..1d0d52ac9ba603 100644 --- a/api/tests/unit_tests/core/app/workflow/layers/test_persistence.py +++ b/api/tests/unit_tests/core/app/workflow/layers/test_persistence.py @@ -3,13 +3,13 @@ import pytest -from core.app.workflow.layers.persistence import ( +from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionStatus, WorkflowType +from graphon.node_events import NodeRunResult +from services.workflow.execution.adapters.persistence import ( PersistenceWorkflowInfo, WorkflowPersistenceLayer, _NodeRuntimeSnapshot, ) -from graphon.enums import BuiltinNodeTypes, WorkflowNodeExecutionStatus, WorkflowType -from graphon.node_events import NodeRunResult def _build_layer() -> WorkflowPersistenceLayer: @@ -48,7 +48,9 @@ def test_update_node_execution_prefers_event_finished_at(monkeypatch: pytest.Mon event_finished_at = datetime(2024, 1, 1, 0, 0, 2, tzinfo=UTC).replace(tzinfo=None) delayed_processing_time = datetime(2024, 1, 1, 0, 0, 10, tzinfo=UTC).replace(tzinfo=None) - monkeypatch.setattr("core.app.workflow.layers.persistence.naive_utc_now", lambda: delayed_processing_time) + monkeypatch.setattr( + "services.workflow.execution.adapters.persistence.naive_utc_now", lambda: delayed_processing_time + ) layer._update_node_execution( node_execution, diff --git a/api/tests/unit_tests/core/app/workflow/layers/test_persistence_inspector_publish.py b/api/tests/unit_tests/core/app/workflow/layers/test_persistence_inspector_publish.py index e23f4a8181435a..3212ec62101ac6 100644 --- a/api/tests/unit_tests/core/app/workflow/layers/test_persistence_inspector_publish.py +++ b/api/tests/unit_tests/core/app/workflow/layers/test_persistence_inspector_publish.py @@ -22,8 +22,8 @@ import pytest -from core.app.workflow.layers import persistence as persistence_mod -from core.app.workflow.layers.persistence import WorkflowPersistenceLayer +from services.workflow.execution.adapters import persistence as persistence_mod +from services.workflow.execution.adapters.persistence import WorkflowPersistenceLayer @pytest.fixture diff --git a/api/tests/unit_tests/core/app/workflow/test_file_runtime.py b/api/tests/unit_tests/core/app/workflow/test_file_runtime.py index e84db1258c16bc..6dc7109a8c04a0 100644 --- a/api/tests/unit_tests/core/app/workflow/test_file_runtime.py +++ b/api/tests/unit_tests/core/app/workflow/test_file_runtime.py @@ -201,7 +201,7 @@ def test_resolve_upload_file_url_signs_internal_urls_and_supports_attachments( INTERNAL_FILES_URL="https://internal.example.com", ) monkeypatch.setattr("core.app.workflow.file_runtime.time.time", lambda: 1700000000) - monkeypatch.setattr("core.app.workflow.file_runtime.os.urandom", lambda _: b"\x01" * 16) + monkeypatch.setattr("libs.signed_query.os.urandom", lambda _: b"\x01" * 16) runtime = _build_runtime() url = runtime.resolve_upload_file_url( diff --git a/api/tests/unit_tests/core/app/workflow/test_node_factory.py b/api/tests/unit_tests/core/app/workflow/test_node_factory.py index 43888eca917a17..620063a7a628d4 100644 --- a/api/tests/unit_tests/core/app/workflow/test_node_factory.py +++ b/api/tests/unit_tests/core/app/workflow/test_node_factory.py @@ -4,8 +4,8 @@ import pytest from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, build_dify_run_context -from core.workflow.node_factory import DifyNodeFactory from graphon.enums import BuiltinNodeTypes +from services.workflow.execution.adapters.node_factory import DifyNodeFactory class DummyNode: @@ -59,7 +59,7 @@ def _node_config(self, config_overrides: Callable[..., None]) -> None: @staticmethod def _stub_node_resolution(monkeypatch: pytest.MonkeyPatch, node_class): monkeypatch.setattr( - "core.workflow.node_factory.resolve_workflow_node_class", + "services.workflow.execution.adapters.node_factory.resolve_workflow_node_class", lambda **_kwargs: node_class, ) @@ -85,7 +85,9 @@ def test_create_node_unknown_type(self): def test_create_node_missing_mapping(self, monkeypatch: pytest.MonkeyPatch): factory = self._factory() - monkeypatch.setattr("core.workflow.node_factory.get_node_type_classes_mapping", lambda: {}) + monkeypatch.setattr( + "services.workflow.execution.adapters.node_factory.get_node_type_classes_mapping", lambda: {} + ) with pytest.raises(ValueError): factory.create_node({"id": "node-1", "data": {"type": BuiltinNodeTypes.START}}) @@ -93,10 +95,10 @@ def test_create_node_missing_mapping(self, monkeypatch: pytest.MonkeyPatch): def test_create_node_missing_latest_class(self, monkeypatch: pytest.MonkeyPatch): factory = self._factory() monkeypatch.setattr( - "core.workflow.node_factory.get_node_type_classes_mapping", + "services.workflow.execution.adapters.node_factory.get_node_type_classes_mapping", lambda: {BuiltinNodeTypes.START: {"1": None}}, ) - monkeypatch.setattr("core.workflow.node_factory.LATEST_VERSION", "latest") + monkeypatch.setattr("services.workflow.execution.adapters.node_factory.LATEST_VERSION", "latest") with pytest.raises(ValueError): factory.create_node({"id": "node-1", "data": {"type": BuiltinNodeTypes.START}}) @@ -112,7 +114,9 @@ def _get_mapping(): selected_versions.append(("snapshot", "called")) return {BuiltinNodeTypes.START: {"1": DummyNode, "2": DummyNodeV2}} - monkeypatch.setattr("core.workflow.node_factory.get_node_type_classes_mapping", _get_mapping) + monkeypatch.setattr( + "services.workflow.execution.adapters.node_factory.get_node_type_classes_mapping", _get_mapping + ) node = factory.create_node({"id": "node-1", "data": {"type": BuiltinNodeTypes.START, "version": "2"}}) @@ -147,14 +151,15 @@ def test_create_node_http_request_branch(self, monkeypatch: pytest.MonkeyPatch): assert isinstance(node, DummyHttpRequestNode) assert "http_request_config" in node.kwargs - def test_create_node_knowledge_retrieval_branch(self, monkeypatch: pytest.MonkeyPatch): + def test_create_node_knowledge_retrieval_branch(self, monkeypatch: pytest.MonkeyPatch, workflow_runtime): factory = self._factory() + factory._workflow_runtime = workflow_runtime self._stub_node_resolution(monkeypatch, DummyKnowledgeRetrievalNode) node = factory.create_node({"id": "node-1", "data": {"type": BuiltinNodeTypes.KNOWLEDGE_RETRIEVAL}}) assert isinstance(node, DummyKnowledgeRetrievalNode) - assert node.kwargs == {} + assert "retrieval" in node.kwargs def test_create_node_document_extractor_branch(self, monkeypatch: pytest.MonkeyPatch): factory = self._factory() diff --git a/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py b/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py index bc67bfc12d56f2..b909ee7a159cd4 100644 --- a/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py +++ b/api/tests/unit_tests/core/app/workflow/test_persistence_layer.py @@ -6,7 +6,6 @@ import pytest from core.app.entities.app_invoke_entities import WorkflowAppGenerateEntity -from core.app.workflow.layers.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer from core.ops.ops_trace_manager import TraceTask, TraceTaskName from core.workflow.system_variables import SystemVariableKey, build_system_variables from graphon.entities import WorkflowNodeExecution, WorkflowStartReason @@ -35,6 +34,7 @@ from graphon.model_runtime.entities.llm_entities import LLMUsage from graphon.node_events import NodeRunResult from graphon.runtime import GraphRuntimeState, ReadOnlyGraphRuntimeStateWrapper, VariablePool +from services.workflow.execution.adapters.persistence import PersistenceWorkflowInfo, WorkflowPersistenceLayer class _RepoRecorder: @@ -138,7 +138,7 @@ def test_prepare_workflow_inputs_excludes_conversation_id(self, monkeypatch: pyt layer, _, _, _ = _make_layer() monkeypatch.setattr( - "core.workflow.workflow_entry.WorkflowEntry.handle_special_values", + "services.workflow.execution.adapters.workflow_entry.WorkflowEntry.handle_special_values", lambda inputs: inputs, ) From 34097adec1d95ee4e0601059bcb3077b27faa9df Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Fri, 9 Oct 2026 12:48:53 +0800 Subject: [PATCH 02/11] test(app): adapt generation and agent execution to injected ports --- api/tests/unit_tests/core/agent/conftest.py | 9 +++ .../core/agent/test_cot_agent_runner.py | 39 ++++-------- .../app/apps/agent_app/test_app_generator.py | 6 +- .../app/apps/agent_app/test_input_guards.py | 18 +++--- .../test_agent_chat_app_generator.py | 12 +++- .../agent_chat/test_agent_chat_app_runner.py | 3 + .../app/apps/completion/test_app_runner.py | 5 +- .../core/app/apps/test_base_app_generator.py | 12 ++-- .../core/app/workflow/test_node_factory.py | 61 +++++++++++++------ 9 files changed, 97 insertions(+), 68 deletions(-) diff --git a/api/tests/unit_tests/core/agent/conftest.py b/api/tests/unit_tests/core/agent/conftest.py index a2aa50172093f6..34aec09d569209 100644 --- a/api/tests/unit_tests/core/agent/conftest.py +++ b/api/tests/unit_tests/core/agent/conftest.py @@ -1,5 +1,9 @@ +from unittest.mock import MagicMock, create_autospec + import pytest +from services.agent.chat.ports import AgentToolInvoker + class DummyTool: def __init__(self, name): @@ -78,3 +82,8 @@ def _factory(**kwargs): return DummyScratchpadUnit(**kwargs) return _factory + + +@pytest.fixture +def agent_tool_invoker() -> MagicMock: + return create_autospec(AgentToolInvoker, instance=True, spec_set=True) diff --git a/api/tests/unit_tests/core/agent/test_cot_agent_runner.py b/api/tests/unit_tests/core/agent/test_cot_agent_runner.py index 3b0ddfc22044ab..b6febdc8957bd2 100644 --- a/api/tests/unit_tests/core/agent/test_cot_agent_runner.py +++ b/api/tests/unit_tests/core/agent/test_cot_agent_runner.py @@ -65,7 +65,9 @@ def _organize_prompt_messages(self): @pytest.fixture -def runner(mocker: MockerFixture, sqlite_engine: Engine, app_records) -> Iterator[DummyRunner]: +def runner( + mocker: MockerFixture, sqlite_engine: Engine, app_records, agent_tool_invoker: MagicMock +) -> Iterator[DummyRunner]: # Prevent BaseAgentRunner __init__ from hitting database mocker.patch( "services.agent.chat.base_runner.BaseAgentRunner.organize_agent_history", @@ -98,6 +100,7 @@ def runner(mocker: MockerFixture, sqlite_engine: Engine, app_records) -> Iterato message = _make_message() runner = DummyRunner( + _tool_invoker=agent_tool_invoker, tenant_id="tenant", application_generate_entity=application_generate_entity, conversation=_make_conversation(), @@ -199,15 +202,12 @@ def test_handle_invoke_action_tool_not_present(self, runner: DummyRunner): response, meta = runner._handle_invoke_action(action, {}, []) assert "there is not a tool named" in response - def test_tool_with_json_string_args(self, runner: DummyRunner, mocker: MockerFixture): + def test_tool_with_json_string_args(self, runner: DummyRunner): action = AgentScratchpadUnit.Action(action_name="tool", action_input=json.dumps({"a": 1})) tool_instance = MagicMock() tool_instances = {"tool": tool_instance} - mocker.patch( - "services.agent.chat.cot_runner.ToolEngine.agent_invoke", - return_value=("result", [], MagicMock(to_dict=lambda: {})), - ) + runner._tool_invoker.return_value = ("result", [], MagicMock(to_dict=lambda: {})) response, meta = runner._handle_invoke_action(action, tool_instances, []) assert response == "result" @@ -249,10 +249,7 @@ def test_run_with_action_and_tool_invocation(self, runner: DummyRunner, mocker: return_value=[action], ) - mocker.patch( - "services.agent.chat.cot_runner.ToolEngine.agent_invoke", - return_value=("ok", [], MagicMock(to_dict=lambda: {})), - ) + runner._tool_invoker.return_value = ("ok", [], MagicMock(to_dict=lambda: {})) runner.agent_callback = None @@ -271,10 +268,7 @@ def test_run_respects_max_iteration_boundary(self, runner: DummyRunner, mocker: return_value=[action], ) - mocker.patch( - "services.agent.chat.cot_runner.ToolEngine.agent_invoke", - return_value=("ok", [], MagicMock(to_dict=lambda: {})), - ) + runner._tool_invoker.return_value = ("ok", [], MagicMock(to_dict=lambda: {})) runner.agent_callback = None @@ -346,10 +340,7 @@ def _handle_side_effect(chunks, usage_dict): handle_output.side_effect = _handle_side_effect runner.model_instance.invoke_llm = MagicMock(return_value=[]) - mocker.patch( - "services.agent.chat.cot_runner.ToolEngine.agent_invoke", - return_value=("ok", [], MagicMock(to_dict=lambda: {})), - ) + runner._tool_invoker.return_value = ("ok", [], MagicMock(to_dict=lambda: {})) fake_prompt_tool = MagicMock() fake_prompt_tool.name = "tool" @@ -428,10 +419,7 @@ def test_run_prompt_tool_update_branch(self, runner: DummyRunner, mocker: Mocker side_effect=[[action], []], ) - mocker.patch( - "services.agent.chat.cot_runner.ToolEngine.agent_invoke", - return_value=("ok", [], MagicMock(to_dict=lambda: {})), - ) + runner._tool_invoker.return_value = ("ok", [], MagicMock(to_dict=lambda: {})) runner.app_config.agent.max_iteration = 5 @@ -484,15 +472,12 @@ def test_init_react_state_resets_state(self, runner: DummyRunner, mocker: Mocker class TestHandleInvokeActionExtended: - def test_tool_with_invalid_json_string_args(self, runner: DummyRunner, mocker: MockerFixture): + def test_tool_with_invalid_json_string_args(self, runner: DummyRunner): action = AgentScratchpadUnit.Action(action_name="tool", action_input="not-json") tool_instance = MagicMock() tool_instances = {"tool": tool_instance} - mocker.patch( - "services.agent.chat.cot_runner.ToolEngine.agent_invoke", - return_value=("ok", ["file1"], MagicMock(to_dict=lambda: {"k": "v"})), - ) + runner._tool_invoker.return_value = ("ok", ["file1"], MagicMock(to_dict=lambda: {"k": "v"})) message_file_ids = [] response, meta = runner._handle_invoke_action(action, tool_instances, message_file_ids) diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py b/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py index 7f86ea01e163e8..768902feda9317 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_app_generator.py @@ -13,7 +13,7 @@ import inspect from decimal import Decimal from types import SimpleNamespace -from unittest.mock import Mock +from unittest.mock import create_autospec import pytest from pytest_mock import MockerFixture @@ -140,12 +140,12 @@ def generator( tool_providers, mocker: MockerFixture, *, annotation_replies, app_records, human_forms ) -> AgentAppGenerator: gen = AgentAppGenerator( - agent_configs=Mock(spec=AgentAppConfigurations), + agent_configs=create_autospec(AgentAppConfigurations, instance=True, spec_set=True), forms=human_forms, annotations=annotation_replies, records=app_records, tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=create_autospec(WorkflowToolQueries, instance=True, spec_set=True), ) mocker.patch(f"{MODULE}.current_app", new=mocker.MagicMock(_get_current_object=mocker.MagicMock())) mocker.patch(f"{MODULE}.contextvars.copy_context", return_value="ctx") diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py b/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py index 020be83c215a3e..a6d23ce9539d66 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py @@ -10,7 +10,7 @@ from types import SimpleNamespace from typing import Any -from unittest.mock import Mock +from unittest.mock import create_autospec import pytest from sqlalchemy.orm import Session @@ -104,12 +104,12 @@ def test_no_guards_passes_through( qm = _FakeQueueManager() handled, query, annotation_reply = AgentAppGenerator( - agent_configs=Mock(spec=AgentAppConfigurations), + agent_configs=create_autospec(AgentAppConfigurations, instance=True, spec_set=True), forms=human_forms, annotations=annotation_replies, records=app_records, tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=create_autospec(WorkflowToolQueries, instance=True, spec_set=True), )._run_input_guards( application_generate_entity=_make_entity("hello"), app_model=_app(), @@ -137,12 +137,12 @@ def test_moderation_override_sanitizes_query( qm = _FakeQueueManager() handled, query, annotation_reply = AgentAppGenerator( - agent_configs=Mock(spec=AgentAppConfigurations), + agent_configs=create_autospec(AgentAppConfigurations, instance=True, spec_set=True), forms=human_forms, annotations=annotation_replies, records=app_records, tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=create_autospec(WorkflowToolQueries, instance=True, spec_set=True), )._run_input_guards( application_generate_entity=_make_entity("leak my secret"), app_model=_app(), @@ -170,12 +170,12 @@ def test_moderation_block_short_circuits( qm = _FakeQueueManager() handled, _, annotation_reply = AgentAppGenerator( - agent_configs=Mock(spec=AgentAppConfigurations), + agent_configs=create_autospec(AgentAppConfigurations, instance=True, spec_set=True), forms=human_forms, annotations=annotation_replies, records=app_records, tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=create_autospec(WorkflowToolQueries, instance=True, spec_set=True), )._run_input_guards( application_generate_entity=_make_entity("forbidden"), app_model=_app(), @@ -211,12 +211,12 @@ def test_annotation_hit_short_circuits( qm = _FakeQueueManager() handled, _, annotation_reply = AgentAppGenerator( - agent_configs=Mock(spec=AgentAppConfigurations), + agent_configs=create_autospec(AgentAppConfigurations, instance=True, spec_set=True), forms=human_forms, annotations=annotation_replies, records=app_records, tool_providers=tool_providers, - workflow_queries=Mock(spec=WorkflowToolQueries), + workflow_queries=create_autospec(WorkflowToolQueries, instance=True, spec_set=True), )._run_input_guards( application_generate_entity=_make_entity("what is your name"), app_model=_app(), diff --git a/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_generator.py b/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_generator.py index fbdeef8d3251e5..e2d36d6fb37528 100644 --- a/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_generator.py @@ -6,7 +6,7 @@ import pytest from pydantic import ValidationError from pytest_mock import MockerFixture -from sqlalchemy.orm import Session +from sqlalchemy.orm import Session, sessionmaker from core.app.apps.exc import GenerateTaskStoppedError from core.app.entities.app_invoke_entities import InvokeFrom @@ -81,11 +81,19 @@ def _message() -> Message: @pytest.fixture -def generator(app_records, mocker: MockerFixture, *, annotation_replies, workflow_runtime): +def generator( + app_records, + mocker: MockerFixture, + sqlite_session_factory: sessionmaker[Session], + *, + annotation_replies, + workflow_runtime, +): gen = AgentChatAppGenerator( dataset_tools=workflow_runtime.dataset_tools, annotations=annotation_replies, records=app_records, + tool_sessions=sqlite_session_factory, ) mocker.patch( "services.app.generation.adapters.agent_chat.current_app", diff --git a/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_runner.py b/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_runner.py index f4cdffd9a32b67..1b363f9657c4ac 100644 --- a/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/agent_chat/test_agent_chat_app_runner.py @@ -1,4 +1,5 @@ from datetime import datetime +from unittest.mock import create_autospec import pytest from pytest_mock import MockerFixture @@ -12,6 +13,7 @@ from graphon.model_runtime.entities.model_entities import ModelFeature, ModelPropertyKey from models.enums import ConversationFromSource from models.model import App, AppMode, Conversation, Message, MessageAnnotation +from services.agent.chat.ports import AgentToolInvoker from services.app.generation.adapters.agent_chat_runner import AgentChatAppRunner @@ -74,6 +76,7 @@ def runner(sqlite_session: Session, *, app_records, workflow_runtime): return AgentChatAppRunner( dataset_tools=workflow_runtime.dataset_tools, records=app_records, + tool_invoker=create_autospec(AgentToolInvoker, instance=True, spec_set=True), ) diff --git a/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py b/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py index 0492a277c24d2a..a03f7c42c3302d 100644 --- a/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/completion/test_app_runner.py @@ -1,6 +1,6 @@ from decimal import Decimal from types import SimpleNamespace -from unittest.mock import MagicMock +from unittest.mock import MagicMock, create_autospec import pytest from pytest_mock import MockerFixture @@ -16,6 +16,7 @@ from models.enums import ConversationFromSource from models.model import App, AppMode, Conversation, IconType, Message from services.app.generation.adapters.completion_runner import CompletionAppRunner +from services.app.generation.retrieval import ApplicationDatasetRetriever APP_ID = "00000000-0000-0000-0000-000000000001" TENANT_ID = "00000000-0000-0000-0000-000000000002" @@ -167,7 +168,7 @@ def test_run_dataset_and_external_tools_flow(self, runner, mocker: MockerFixture runner.recalc_llm_max_tokens = MagicMock() runner._handle_invoke_result = MagicMock() - dataset_retrieval = MagicMock() + dataset_retrieval = create_autospec(ApplicationDatasetRetriever, instance=True, spec_set=True) dataset_retrieval.retrieve.return_value = ("ctx", ["file1"]) runner._retrieval = lambda _: dataset_retrieval diff --git a/api/tests/unit_tests/core/app/apps/test_base_app_generator.py b/api/tests/unit_tests/core/app/apps/test_base_app_generator.py index f0a9e40f911d21..f603b90e763601 100644 --- a/api/tests/unit_tests/core/app/apps/test_base_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_base_app_generator.py @@ -1,5 +1,5 @@ import logging -from unittest.mock import Mock +from unittest.mock import Mock, call, create_autospec import pytest from sqlalchemy import inspect @@ -592,14 +592,13 @@ def _gen(): assert converted[1] == "event: ping\n\n" def test_get_draft_var_saver_factory_debugger(self): - from unittest.mock import Mock - from core.app.apps.draft_variable_saver import DraftVariableSaver, DraftVariableSaverFactory from core.app.entities.app_invoke_entities import InvokeFrom from models import Account - saver = Mock(spec=DraftVariableSaver) - factory = Mock(spec=DraftVariableSaverFactory, return_value=saver) + saver = create_autospec(DraftVariableSaver, instance=True, spec_set=True) + factory = create_autospec(DraftVariableSaverFactory, instance=True, spec_set=True) + factory.return_value = saver provider = Mock(return_value=factory) generator = AppInputAdapter(draft_variable_saver=provider) account = Account(name="Tester", email="tester@example.com") @@ -612,6 +611,7 @@ def test_get_draft_var_saver_factory_debugger(self): is saver ) provider.assert_called_once_with("tenant-id", account) - factory.assert_called_once_with( + assert factory.call_count == 1 + assert factory.call_args == call( app_id="app-id", node_id="node-id", node_type=BuiltinNodeTypes.START, node_execution_id="node-exec-id" ) diff --git a/api/tests/unit_tests/core/app/workflow/test_node_factory.py b/api/tests/unit_tests/core/app/workflow/test_node_factory.py index 620063a7a628d4..dc31dcb0861303 100644 --- a/api/tests/unit_tests/core/app/workflow/test_node_factory.py +++ b/api/tests/unit_tests/core/app/workflow/test_node_factory.py @@ -1,25 +1,41 @@ -from collections.abc import Callable -from types import SimpleNamespace +from collections.abc import Callable, Mapping import pytest from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom, build_dify_run_context -from graphon.enums import BuiltinNodeTypes +from graphon.entities import GraphInitParams +from graphon.entities.base_node_data import BaseNodeData +from graphon.enums import BuiltinNodeTypes, NodeType +from graphon.runtime import GraphRuntimeState, VariablePool from services.workflow.execution.adapters.node_factory import DifyNodeFactory +from services.workflow.execution.ports import WorkflowRuntime class DummyNode: - def __init__(self, *, node_id, data, graph_init_params, graph_runtime_state, **kwargs): + def __init__( + self, + *, + node_id: str, + data: dict[str, object], + graph_init_params: GraphInitParams, + graph_runtime_state: GraphRuntimeState, + **kwargs: object, + ) -> None: self.id = node_id self.data = data self.graph_init_params = graph_init_params self.graph_runtime_state = graph_runtime_state self.kwargs = kwargs + @classmethod + def validate_node_data(cls, node_data: BaseNodeData | Mapping[str, object]) -> BaseNodeData: + payload = node_data.model_dump(mode="python") if isinstance(node_data, BaseNodeData) else dict(node_data) + return BaseNodeData.model_validate(payload) + class DummyCodeNode(DummyNode): @classmethod - def default_code_providers(cls): + def default_code_providers(cls) -> tuple[()]: return () @@ -57,13 +73,13 @@ def _node_config(self, config_overrides: Callable[..., None]) -> None: ) @staticmethod - def _stub_node_resolution(monkeypatch: pytest.MonkeyPatch, node_class): + def _stub_node_resolution(monkeypatch: pytest.MonkeyPatch, node_class: type[DummyNode]) -> None: monkeypatch.setattr( "services.workflow.execution.adapters.node_factory.resolve_workflow_node_class", lambda **_kwargs: node_class, ) - def _factory(self): + def _factory(self) -> DifyNodeFactory: run_context = build_dify_run_context( tenant_id="tenant", app_id="app", @@ -73,17 +89,22 @@ def _factory(self): ) return DifyNodeFactory( - graph_init_params=SimpleNamespace(run_context=run_context), - graph_runtime_state=SimpleNamespace(), + graph_init_params=GraphInitParams( + workflow_id="workflow-id", + graph_config={}, + run_context=run_context, + call_depth=0, + ), + graph_runtime_state=GraphRuntimeState(variable_pool=VariablePool(), start_at=0), ) - def test_create_node_unknown_type(self): + def test_create_node_unknown_type(self) -> None: factory = self._factory() with pytest.raises(ValueError): factory.create_node({"id": "node-1", "data": {"type": "unknown"}}) - def test_create_node_missing_mapping(self, monkeypatch: pytest.MonkeyPatch): + def test_create_node_missing_mapping(self, monkeypatch: pytest.MonkeyPatch) -> None: factory = self._factory() monkeypatch.setattr( "services.workflow.execution.adapters.node_factory.get_node_type_classes_mapping", lambda: {} @@ -92,7 +113,7 @@ def test_create_node_missing_mapping(self, monkeypatch: pytest.MonkeyPatch): with pytest.raises(ValueError): factory.create_node({"id": "node-1", "data": {"type": BuiltinNodeTypes.START}}) - def test_create_node_missing_latest_class(self, monkeypatch: pytest.MonkeyPatch): + def test_create_node_missing_latest_class(self, monkeypatch: pytest.MonkeyPatch) -> None: factory = self._factory() monkeypatch.setattr( "services.workflow.execution.adapters.node_factory.get_node_type_classes_mapping", @@ -103,14 +124,14 @@ def test_create_node_missing_latest_class(self, monkeypatch: pytest.MonkeyPatch) with pytest.raises(ValueError): factory.create_node({"id": "node-1", "data": {"type": BuiltinNodeTypes.START}}) - def test_create_node_selects_versioned_class(self, monkeypatch: pytest.MonkeyPatch): + def test_create_node_selects_versioned_class(self, monkeypatch: pytest.MonkeyPatch) -> None: factory = self._factory() selected_versions: list[tuple[str, str]] = [] class DummyNodeV2(DummyNode): pass - def _get_mapping(): + def _get_mapping() -> Mapping[NodeType, Mapping[str, type[DummyNode]]]: selected_versions.append(("snapshot", "called")) return {BuiltinNodeTypes.START: {"1": DummyNode, "2": DummyNodeV2}} @@ -124,7 +145,7 @@ def _get_mapping(): assert node.id == "node-1" assert selected_versions == [("snapshot", "called")] - def test_create_node_code_branch(self, monkeypatch: pytest.MonkeyPatch): + def test_create_node_code_branch(self, monkeypatch: pytest.MonkeyPatch) -> None: factory = self._factory() self._stub_node_resolution(monkeypatch, DummyCodeNode) @@ -133,7 +154,7 @@ def test_create_node_code_branch(self, monkeypatch: pytest.MonkeyPatch): assert isinstance(node, DummyCodeNode) assert node.id == "node-1" - def test_create_node_template_transform_branch(self, monkeypatch: pytest.MonkeyPatch): + def test_create_node_template_transform_branch(self, monkeypatch: pytest.MonkeyPatch) -> None: factory = self._factory() self._stub_node_resolution(monkeypatch, DummyTemplateTransformNode) @@ -142,7 +163,7 @@ def test_create_node_template_transform_branch(self, monkeypatch: pytest.MonkeyP assert isinstance(node, DummyTemplateTransformNode) assert "jinja2_template_renderer" in node.kwargs - def test_create_node_http_request_branch(self, monkeypatch: pytest.MonkeyPatch): + def test_create_node_http_request_branch(self, monkeypatch: pytest.MonkeyPatch) -> None: factory = self._factory() self._stub_node_resolution(monkeypatch, DummyHttpRequestNode) @@ -151,7 +172,9 @@ def test_create_node_http_request_branch(self, monkeypatch: pytest.MonkeyPatch): assert isinstance(node, DummyHttpRequestNode) assert "http_request_config" in node.kwargs - def test_create_node_knowledge_retrieval_branch(self, monkeypatch: pytest.MonkeyPatch, workflow_runtime): + def test_create_node_knowledge_retrieval_branch( + self, monkeypatch: pytest.MonkeyPatch, workflow_runtime: WorkflowRuntime + ) -> None: factory = self._factory() factory._workflow_runtime = workflow_runtime self._stub_node_resolution(monkeypatch, DummyKnowledgeRetrievalNode) @@ -161,7 +184,7 @@ def test_create_node_knowledge_retrieval_branch(self, monkeypatch: pytest.Monkey assert isinstance(node, DummyKnowledgeRetrievalNode) assert "retrieval" in node.kwargs - def test_create_node_document_extractor_branch(self, monkeypatch: pytest.MonkeyPatch): + def test_create_node_document_extractor_branch(self, monkeypatch: pytest.MonkeyPatch) -> None: factory = self._factory() self._stub_node_resolution(monkeypatch, DummyDocumentExtractorNode) From 48f58dd76f1fbd50c7c4fc77b493b747768635a1 Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Thu, 15 Oct 2026 16:26:29 +0800 Subject: [PATCH 03/11] fix(workflow): address review findings in #43689 --- .../apps/advanced_chat/test_app_generator.py | 10 ++++-- .../test_app_runner_input_moderation.py | 3 +- .../pipeline/test_pipeline_config_manager.py | 5 ++- .../app/apps/pipeline/test_pipeline_runner.py | 32 +++++++++++-------- .../core/app/apps/test_base_app_generator.py | 25 ++++++++++----- .../app/apps/test_workflow_app_generator.py | 9 ++++-- .../apps/workflow/test_app_generator_extra.py | 2 +- 7 files changed, 56 insertions(+), 30 deletions(-) diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py index fb35d10836fd89..3169443e7c9efb 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_generator.py @@ -465,9 +465,14 @@ def _record_commit(session: Session) -> None: event.listen(sqlite_session, "after_commit", _record_commit) captured: dict[str, object] = {} thread_data: dict[str, object] = {} + pause_layer_kwargs: dict[str, object] = {} init_records = MagicMock(return_value=(conversation, message)) get_thread_messages_length = MagicMock(return_value=3) + def _build_pause_layer(**kwargs: object) -> str: + pause_layer_kwargs.update(kwargs) + return "pause-layer" + monkeypatch.setattr(generator._runtime.chat_records, "initialize", init_records) monkeypatch.setattr(generator._runtime.chat_records, "dialogue_count", get_thread_messages_length) monkeypatch.setattr( @@ -476,7 +481,7 @@ def _record_commit(session: Session) -> None: ) monkeypatch.setattr( "services.workflow.execution.adapters.chatflow.app_generator.PauseStatePersistenceLayer", - lambda **kwargs: "pause-layer", + _build_pause_layer, ) monkeypatch.setattr( "services.workflow.execution.adapters.chatflow.app_generator.current_app", @@ -533,6 +538,7 @@ def is_alive(self): assert thread_data["joined"] is True assert thread_data["join_timeout"] == 300 assert "pause-layer" in thread_data["kwargs"]["graph_engine_layers"] + assert pause_layer_kwargs["human_form_reader"] is workflow_runtime.human_form_reader assert generator._dialogue_count == 3 get_thread_messages_length.assert_called_once_with(conversation.id) assert commit_count == 0 @@ -818,7 +824,7 @@ def run(self): monkeypatch.setattr( "services.workflow.execution.adapters.chatflow.app_generator.AdvancedChatAppRunner", _Runner ) - restore_workflow_run_graph = MagicMock() + restore_workflow_run_graph = MagicMock(side_effect=lambda workflow, _run_id: workflow) monkeypatch.setattr(generator._runtime.contexts, "restore_graph", restore_workflow_run_graph) generator._generate_worker( diff --git a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py index 810530ef84467d..5547b9646eec5a 100644 --- a/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py +++ b/api/tests/unit_tests/core/app/apps/advanced_chat/test_app_runner_input_moderation.py @@ -11,6 +11,7 @@ from core.moderation.base import ModerationError from models.model import App, AppMode, Conversation, IconType, Message from models.workflow import Workflow, WorkflowType +from repositories.workflow.definition_repository import workflow_snapshot from services.workflow.execution.adapters.chatflow.app_runner import AdvancedChatAppRunner from services.workflow.execution.ports import WorkflowRuntime @@ -90,7 +91,7 @@ def build_runner(sqlite_session: Session, *, workflow_runtime: WorkflowRuntime): message=message, dialogue_count=1, variable_loader=MagicMock(), - workflow=workflow, + workflow=workflow_snapshot(workflow), system_user_id=str(uuid4()), app=app, workflow_execution_repository=MagicMock(), diff --git a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_config_manager.py b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_config_manager.py index 9f8eaa0fb72566..22f1e790930565 100644 --- a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_config_manager.py +++ b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_config_manager.py @@ -7,6 +7,7 @@ from models.dataset import Pipeline from models.model import AppMode from models.workflow import Workflow, WorkflowType +from repositories.workflow.definition_repository import workflow_snapshot from services.workflow.execution.adapters.pipeline.pipeline_config_manager import PipelineConfigManager @@ -34,7 +35,9 @@ def test_get_pipeline_config(mocker: MockerFixture): ) mocker.patch.object(module, "PipelineConfig", side_effect=lambda **kwargs: SimpleNamespace(**kwargs)) - result = PipelineConfigManager.get_pipeline_config(pipeline=pipeline, workflow=workflow, start_node_id="start") + result = PipelineConfigManager.get_pipeline_config( + pipeline=pipeline, workflow=workflow_snapshot(workflow), start_node_id="start" + ) assert result.tenant_id == "tenant" assert result.app_id == "pipe1" diff --git a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py index eea9e1b82a8820..c12c6c34498df9 100644 --- a/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py +++ b/api/tests/unit_tests/core/app/apps/pipeline/test_pipeline_runner.py @@ -13,7 +13,6 @@ ``UserFrom``, and patched DB/runtime dependencies used by the runner. """ -import json from types import SimpleNamespace from unittest.mock import MagicMock @@ -31,6 +30,7 @@ from models.model import EndUser from models.workflow import Workflow, WorkflowType from repositories.knowledge.document_repository import SQLAlchemyDocumentRepository +from repositories.workflow.definition_repository import workflow_snapshot from services.workflow.execution.adapters.pipeline.pipeline_runner import PipelineRunner from services.workflow.execution.ports import WorkflowRuntime from tests.unit_tests.model_factories import make_dataset, make_document, make_end_user, make_workflow @@ -125,7 +125,7 @@ def runner(sqlite_engine: Engine, *, workflow_runtime: WorkflowRuntime): app_generate_entity = _build_app_generate_entity() queue_manager = MagicMock() variable_loader = MagicMock() - workflow = _workflow() + workflow = workflow_snapshot(_workflow()) workflow_execution_repository = MagicMock() workflow_node_execution_repository = MagicMock() @@ -151,22 +151,26 @@ def test_get_workflow_returns_workflow(runner, sqlite_session: Session): def test_init_rag_pipeline_graph_invalid_config(mocker, runner): - workflow = _workflow(graph={}) - with pytest.raises(ValueError): - runner._init_rag_pipeline_graph(workflow=workflow, graph_runtime_state=MagicMock()) + runner._init_rag_pipeline_graph( + workflow=workflow_snapshot(_workflow(graph={})), graph_runtime_state=MagicMock() + ) - workflow.graph = json.dumps({"nodes": "bad", "edges": []}) with pytest.raises(ValueError): - runner._init_rag_pipeline_graph(workflow=workflow, graph_runtime_state=MagicMock()) + runner._init_rag_pipeline_graph( + workflow=workflow_snapshot(_workflow(graph={"nodes": "bad", "edges": []})), + graph_runtime_state=MagicMock(), + ) - workflow.graph = json.dumps({"nodes": [], "edges": "bad"}) with pytest.raises(ValueError): - runner._init_rag_pipeline_graph(workflow=workflow, graph_runtime_state=MagicMock()) + runner._init_rag_pipeline_graph( + workflow=workflow_snapshot(_workflow(graph={"nodes": [], "edges": "bad"})), + graph_runtime_state=MagicMock(), + ) def test_init_rag_pipeline_graph_not_found(mocker, runner): - workflow = _workflow() + workflow = workflow_snapshot(_workflow()) mocker.patch.object(module.Graph, "init", return_value=None) with pytest.raises(ValueError): @@ -237,7 +241,7 @@ def test_run_pipeline_not_found(sqlite_engine: Engine, *, workflow_runtime: Work application_generate_entity=app_generate_entity, queue_manager=MagicMock(), variable_loader=MagicMock(), - workflow=_workflow(), + workflow=workflow_snapshot(_workflow()), system_user_id="sys", workflow_execution_repository=MagicMock(), workflow_node_execution_repository=MagicMock(), @@ -329,7 +333,7 @@ def test_run_workflow_not_initialized( application_generate_entity=app_generate_entity, queue_manager=MagicMock(), variable_loader=MagicMock(), - workflow=_workflow(), + workflow=workflow_snapshot(_workflow()), system_user_id="sys", workflow_execution_repository=MagicMock(), workflow_node_execution_repository=MagicMock(), @@ -356,7 +360,7 @@ def test_run_single_iteration_path( application_generate_entity=app_generate_entity, queue_manager=MagicMock(), variable_loader=MagicMock(), - workflow=_workflow(), + workflow=workflow_snapshot(_workflow()), system_user_id="sys", workflow_execution_repository=MagicMock(), workflow_node_execution_repository=MagicMock(), @@ -420,7 +424,7 @@ def test_run_normal_path_builds_graph( application_generate_entity=app_generate_entity, queue_manager=MagicMock(), variable_loader=MagicMock(), - workflow=workflow, + workflow=workflow_snapshot(workflow), system_user_id="sys", workflow_execution_repository=MagicMock(), workflow_node_execution_repository=MagicMock(), diff --git a/api/tests/unit_tests/core/app/apps/test_base_app_generator.py b/api/tests/unit_tests/core/app/apps/test_base_app_generator.py index f603b90e763601..f31cb255cf5f2f 100644 --- a/api/tests/unit_tests/core/app/apps/test_base_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_base_app_generator.py @@ -2,17 +2,18 @@ from unittest.mock import Mock, call, create_autospec import pytest -from sqlalchemy import inspect from sqlalchemy.orm import Session, sessionmaker from graphon.enums import BuiltinNodeTypes, WorkflowExecutionStatus from graphon.variables.input_entities import VariableEntity, VariableEntityType -from models import CreatorUserRole, Workflow, WorkflowRun, WorkflowRunTriggeredFrom, WorkflowType +from models import CreatorUserRole, WorkflowRun, WorkflowRunTriggeredFrom, WorkflowType +from repositories.workflow.definition_repository import workflow_snapshot from repositories.workflow.runtime_context_repository import WorkflowRuntimeContextRepository from services.app.generation.input_adapter import AppInputAdapter from services.app.generation.response import convert_to_event_stream from services.errors.app import WorkflowNotFoundError from services.workflow.execution.generation_service import consume_stream, join_worker +from tests.unit_tests.model_factories import make_workflow def _workflow_run(*, graph: str | None) -> WorkflowRun: @@ -33,19 +34,25 @@ def _workflow_run(*, graph: str | None) -> WorkflowRun: def test_restore_workflow_run_graph(sqlite_session: Session): - workflow = Workflow(id="workflow-1", tenant_id="tenant-1", app_id="app-1", graph='{"nodes": [{"id": "edited"}]}') + workflow = make_workflow( + workflow_id="workflow-1", + tenant_id="tenant-1", + app_id="app-1", + graph='{"nodes": [{"id": "edited"}]}', + ) workflow_run = _workflow_run(graph='{"nodes": [{"id": "paused"}]}') sqlite_session.add(workflow_run) sqlite_session.commit() - WorkflowRuntimeContextRepository(sessionmaker(bind=sqlite_session.get_bind())).restore_graph( - workflow=workflow, + snapshot = workflow_snapshot(workflow) + restored = WorkflowRuntimeContextRepository(sessionmaker(bind=sqlite_session.get_bind())).restore_graph( + workflow=snapshot, workflow_run_id="run-id", ) assert sqlite_session.get(WorkflowRun, "run-id") is workflow_run - assert workflow.graph == '{"nodes": [{"id": "paused"}]}' - assert not inspect(workflow).attrs.graph.history.has_changes() + assert restored.graph == '{"nodes": [{"id": "paused"}]}' + assert snapshot.graph == '{"nodes": [{"id": "edited"}]}' @pytest.mark.parametrize( @@ -63,7 +70,9 @@ def test_restore_workflow_run_graph_requires_persisted_snapshot( with pytest.raises(WorkflowNotFoundError): WorkflowRuntimeContextRepository(sessionmaker(bind=sqlite_session.get_bind())).restore_graph( - workflow=Workflow(id="workflow-1", tenant_id="tenant-1", app_id="app-1", graph="{}"), + workflow=workflow_snapshot( + make_workflow(workflow_id="workflow-1", tenant_id="tenant-1", app_id="app-1", graph="{}") + ), workflow_run_id=workflow_run_id, ) diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py index 55008a52d7c576..a79d9d7ac3ec9a 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_generator.py @@ -20,6 +20,7 @@ from models.enums import CreatorUserRole, EndUserType, WorkflowRunTriggeredFrom from models.model import App, AppMode, EndUser from models.workflow import Workflow, WorkflowKind, WorkflowNodeExecutionTriggeredFrom, WorkflowRun +from repositories.workflow.definition_repository import workflow_snapshot from services.workflow.execution.adapters.workflow.app_generator import WorkflowAppGenerator from services.workflow.execution.ports import WorkflowRuntime from tests.unit_tests.model_factories import make_workflow @@ -241,7 +242,7 @@ def test_resume_delegates_to_generate( result = generator.resume( app_model=app, - workflow=workflow, + workflow=workflow_snapshot(workflow), user=end_user, application_generate_entity=application_generate_entity, graph_runtime_state=runtime_state, @@ -346,7 +347,9 @@ def is_alive(self): assert result == "converted" graph_engine_layers = worker_kwargs["kwargs"]["graph_engine_layers"] assert graph_engine_layers[0] == "base-layer" - assert isinstance(graph_engine_layers[1], PauseStatePersistenceLayer) + pause_layer = graph_engine_layers[1] + assert isinstance(pause_layer, PauseStatePersistenceLayer) + assert pause_layer._human_form_reader is workflow_runtime.human_form_reader assert worker_kwargs["kwargs"]["graph_runtime_state"] is graph_runtime_state assert worker_kwargs["joined"] is True assert worker_kwargs["join_timeout"] == 300 @@ -438,7 +441,7 @@ def is_alive(self): result = generator.resume( app_model=app, - workflow=workflow, + workflow=workflow_snapshot(workflow), user=end_user, application_generate_entity=application_generate_entity, graph_runtime_state=runtime_state, diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py b/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py index e7e89f5cc127f1..6e6e1d64719448 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_app_generator_extra.py @@ -646,7 +646,7 @@ def run(self): lambda flask_app, context_vars: contextlib.nullcontext(), ) monkeypatch.setattr("services.workflow.execution.adapters.workflow.app_generator.WorkflowAppRunner", _Runner) - restore_workflow_run_graph = Mock() + restore_workflow_run_graph = Mock(side_effect=lambda workflow, _run_id: workflow) monkeypatch.setattr(generator._runtime.contexts, "restore_graph", restore_workflow_run_graph) app_config = WorkflowUIBasedAppConfig( From 80142f613b23543d246384d8d137d13f78753bcc Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Tue, 20 Oct 2026 11:50:21 +0800 Subject: [PATCH 04/11] test(api): adapt tests to merged execution interfaces --- .../handlers/test_workflow_app_runner_handler.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/api/tests/unit_tests/extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py b/api/tests/unit_tests/extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py index 0886568e002d70..bee35f933efafd 100644 --- a/api/tests/unit_tests/extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py +++ b/api/tests/unit_tests/extensions/otel/decorators/handlers/test_workflow_app_runner_handler.py @@ -12,6 +12,7 @@ from opentelemetry.trace import StatusCode from sqlalchemy import Engine +from extensions.application_services.workflow import WorkflowExecutionDependencies from extensions.otel.decorators.handlers.workflow_app_runner_handler import WorkflowAppRunnerHandler from extensions.otel.semconv import DifySpanAttributes, GenAIAttributes from services.workflow_run_agg import WorkflowRunAgg @@ -19,8 +20,10 @@ from tests.unit_tests.services.workflow.test_workflow_run_agg import make_workflow_runner -def test_service_driver_emits_workflow_span(sqlite_engine: Engine) -> None: - runner, runs, nodes = make_workflow_runner(sqlite_engine) +def test_service_driver_emits_workflow_span( + sqlite_engine: Engine, workflow_runtime: WorkflowExecutionDependencies +) -> None: + runner, runs, nodes = make_workflow_runner(sqlite_engine, workflow_runtime) exporter = InMemorySpanExporter() provider = TracerProvider() provider.add_span_processor(SimpleSpanProcessor(exporter)) From 0b2bf8004541ccdca5a64b5bedc2184e3d1164c2 Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Mon, 26 Oct 2026 20:58:23 +0800 Subject: [PATCH 05/11] Adapt migrated runner tests to explicit typed service ports --- .../app/apps/agent_app/test_input_guards.py | 181 +++++++++++++----- .../apps/test_message_based_app_generator.py | 3 +- .../core/app/apps/test_workflow_app_runner.py | 2 +- .../services/test_workflow_run_agg.py | 2 +- .../services/tracing/test_recovery.py | 18 +- 5 files changed, 145 insertions(+), 61 deletions(-) diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py b/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py index 0383fa75f6c177..eedb4fd4b50e9c 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_input_guards.py @@ -8,95 +8,172 @@ from __future__ import annotations -from types import SimpleNamespace +from collections.abc import Mapping +from typing import override from unittest.mock import create_autospec import pytest import core.moderation.input_moderation as input_moderation_mod -import services.annotation.reply_service as annotation_mod -from core.app.entities.app_invoke_entities import InvokeFrom +from core.app.app_config.entities import ( + AppAdditionalFeatures, + EasyUIBasedAppConfig, + EasyUIBasedAppModelConfigFrom, + ModelConfigEntity, + PromptTemplateEntity, +) +from core.app.apps.base_app_queue_manager import AppQueueManager, PublishFrom +from core.app.entities.app_invoke_entities import AgentAppGenerateEntity, InvokeFrom from core.app.entities.queue_entities import ( + AppQueueEvent, QueueLLMChunkEvent, QueueMessageEndEvent, ) from core.moderation.base import ModerationError -from models.model import App, AppMode, Message, MessageAnnotation +from core.moderation.input_moderation import InputModeration +from models.annotation_reply import AnnotationReplies, AnnotationReply +from models.model import App, AppMode, Message from models.tool_runtime_contracts import WorkflowToolQueries +from repositories.app.generation_repository import AppGenerationRepository +from repositories.tools.provider_repository import ToolProviderRepository from services.app.generation.adapters.agent_app import AgentAppGenerator from services.app.generation.agent_config import AgentAppConfigurations +from tests.helpers.unit.core.model_fixtures import make_model_config from tests.helpers.unit.model_factories import make_app, make_message -class _FakeQueueManager: +class _FakeQueueManager(AppQueueManager): def __init__(self) -> None: - self.events: list[object] = [] + self.events: list[AppQueueEvent] = [] + + @override + def publish(self, event: AppQueueEvent, pub_from: PublishFrom) -> None: + del pub_from + self.events.append(event) - def publish(self, event: object, _from: object) -> None: + @override + def _publish(self, event: AppQueueEvent, pub_from: PublishFrom) -> None: + del pub_from self.events.append(event) -def _make_entity(query: str = "hello") -> SimpleNamespace: - return SimpleNamespace( - app_config=SimpleNamespace(app_id="app-1", tenant_id="tenant-1"), - model_conf=SimpleNamespace(model="gpt-4o-mini"), +def _app_config() -> EasyUIBasedAppConfig: + return EasyUIBasedAppConfig( + tenant_id="tenant-1", + app_id="app-1", + app_mode=AppMode.AGENT, + app_model_config_from=EasyUIBasedAppModelConfigFrom.APP_LATEST_CONFIG, + app_model_config_id=None, + app_model_config_dict={}, + model=ModelConfigEntity(provider="test-provider", model="gpt-4o-mini", mode="chat"), + prompt_template=PromptTemplateEntity( + prompt_type=PromptTemplateEntity.PromptType.SIMPLE, + simple_prompt_template="Hello", + ), + additional_features=AppAdditionalFeatures(), + variables=[], + ) + + +def _make_entity(query: str = "hello") -> AgentAppGenerateEntity: + return AgentAppGenerateEntity( + task_id="task-1", + app_config=_app_config(), + model_conf=make_model_config(provider="test-provider", model="gpt-4o-mini", mode="chat"), + file_upload_config=None, + conversation_id=None, inputs={}, query=query, + files=[], + parent_message_id=None, trace_manager=None, user_id="user-1", + stream=False, invoke_from=InvokeFrom.WEB_APP, + extras={}, + call_depth=0, + agent_id="agent-1", + agent_config_snapshot_id="snapshot-1", + agent_config_version_kind="snapshot", + agent_session_scope_config_version_id="snapshot-1", + agent_llm_gateway_enabled=True, ) def _app() -> App: - return make_app(name="Agent App", mode=AppMode.AGENT_CHAT, icon_type=None) + return make_app( + app_id="app-1", + tenant_id="tenant-1", + name="Agent App", + mode=AppMode.AGENT, + icon_type=None, + ) def _message() -> Message: return make_message(message_id="msg-1") -def _patch_moderation(monkeypatch: pytest.MonkeyPatch, *, returns=None, raises: Exception | None = None) -> None: - class _FakeModeration: - def check(self, **_kwargs: object): - if raises is not None: - raise raises - return returns +ModerationResult = tuple[bool, Mapping[str, object], str] + + +def _patch_moderation( + monkeypatch: pytest.MonkeyPatch, + *, + returns: ModerationResult | None = None, + raises: Exception | None = None, +) -> None: + def check(_moderation: InputModeration, **_kwargs: object) -> ModerationResult: + if raises is not None: + raise raises + if returns is None: + raise AssertionError("a moderation result is required when no exception is configured") + return returns - monkeypatch.setattr(input_moderation_mod, "InputModeration", _FakeModeration) + monkeypatch.setattr(input_moderation_mod.InputModeration, "check", check) -def _patch_annotation(monkeypatch: pytest.MonkeyPatch, *, reply=None) -> None: - class _FakeAnnotation: - def query(self, **_kwargs: object): - return reply +def _patch_annotation( + monkeypatch: pytest.MonkeyPatch, + annotations: AnnotationReplies, + *, + reply: AnnotationReply | None, +) -> None: + def query(**_kwargs: object) -> AnnotationReply | None: + return reply - monkeypatch.setattr(annotation_mod.AnnotationReplyService, "query", _FakeAnnotation.query) + monkeypatch.setattr(annotations, "query", query) -def _answer_text(events: list[object]) -> str: +def _answer_text(events: list[AppQueueEvent]) -> str: end = next(e for e in events if isinstance(e, QueueMessageEndEvent)) - return end.llm_result.message.content + assert end.llm_result is not None + content = end.llm_result.message.content + assert isinstance(content, str) + return content -def _saved_user_query(events: list[object]) -> str: +def _saved_user_query(events: list[AppQueueEvent]) -> str: end = next(e for e in events if isinstance(e, QueueMessageEndEvent)) + assert end.llm_result is not None prompt_messages = end.llm_result.prompt_messages assert len(prompt_messages) == 1 - return prompt_messages[0].content + content = prompt_messages[0].content + assert isinstance(content, str) + return content class TestRunInputGuards: def test_no_guards_passes_through( self, - tool_providers, + tool_providers: ToolProviderRepository, monkeypatch: pytest.MonkeyPatch, *, - annotation_replies, - app_records, - ): + annotation_replies: AnnotationReplies, + app_records: AppGenerationRepository, + ) -> None: _patch_moderation(monkeypatch, returns=(False, {}, "hello")) - _patch_annotation(monkeypatch, reply=None) + _patch_annotation(monkeypatch, annotation_replies, reply=None) qm = _FakeQueueManager() handled, query, annotation_reply = AgentAppGenerator( @@ -119,14 +196,14 @@ def test_no_guards_passes_through( def test_moderation_override_sanitizes_query( self, - tool_providers, + tool_providers: ToolProviderRepository, monkeypatch: pytest.MonkeyPatch, *, - annotation_replies, - app_records, - ): + annotation_replies: AnnotationReplies, + app_records: AppGenerationRepository, + ) -> None: _patch_moderation(monkeypatch, returns=(True, {}, "[redacted]")) - _patch_annotation(monkeypatch, reply=None) + _patch_annotation(monkeypatch, annotation_replies, reply=None) qm = _FakeQueueManager() handled, query, annotation_reply = AgentAppGenerator( @@ -149,14 +226,14 @@ def test_moderation_override_sanitizes_query( def test_moderation_block_short_circuits( self, - tool_providers, + tool_providers: ToolProviderRepository, monkeypatch: pytest.MonkeyPatch, *, - annotation_replies, - app_records, - ): + annotation_replies: AnnotationReplies, + app_records: AppGenerationRepository, + ) -> None: _patch_moderation(monkeypatch, raises=ModerationError("blocked preset answer")) - _patch_annotation(monkeypatch, reply=None) + _patch_annotation(monkeypatch, annotation_replies, reply=None) qm = _FakeQueueManager() handled, _, annotation_reply = AgentAppGenerator( @@ -180,21 +257,18 @@ def test_moderation_block_short_circuits( def test_annotation_hit_short_circuits( self, - tool_providers, + tool_providers: ToolProviderRepository, monkeypatch: pytest.MonkeyPatch, *, - annotation_replies, - app_records, - ): + annotation_replies: AnnotationReplies, + app_records: AppGenerationRepository, + ) -> None: _patch_moderation(monkeypatch, returns=(False, {}, "what is your name")) - annotation = MessageAnnotation( - app_id="app-1", - question="what is your name", + annotation = AnnotationReply( + id="anno-1", content="I am the annotated Iris.", - account_id="account-1", ) - annotation.id = "anno-1" - _patch_annotation(monkeypatch, reply=annotation) + _patch_annotation(monkeypatch, annotation_replies, reply=annotation) qm = _FakeQueueManager() handled, _, annotation_reply = AgentAppGenerator( @@ -211,5 +285,6 @@ def test_annotation_hit_short_circuits( ) assert handled is True + assert annotation_reply is not None assert annotation_reply.id == "anno-1" assert qm.events == [] diff --git a/api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py b/api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py index d13bc7235c51f7..caf23705ba648f 100644 --- a/api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/test_message_based_app_generator.py @@ -17,7 +17,7 @@ from models.model import App, AppMode, Conversation, Message from services.app.generation.message_records import MessageBasedAppGenerator from services.errors.app_model_config import AppModelConfigBrokenError -from tests.helpers.unit.model_factories import make_app +from tests.helpers.unit.model_factories import make_app, make_end_user @dataclass(eq=False, repr=False) @@ -156,6 +156,7 @@ def process(self): queue_manager=SimpleNamespace(), conversation=Conversation(id="conv", app_id="app"), message=Message(id="msg", app_id="app", conversation_id="conv"), + user=make_end_user(end_user_id="user-id"), stream=False, ) diff --git a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner.py b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner.py index 876d49094c331a..d4a4f204426e2b 100644 --- a/api/tests/unit_tests/core/app/apps/test_workflow_app_runner.py +++ b/api/tests/unit_tests/core/app/apps/test_workflow_app_runner.py @@ -849,7 +849,7 @@ def publish(self, event, publish_from): @pytest.mark.parametrize("ownership", ["canonical", "parent", "legacy"]) def test_nested_engine_events_retain_both_container_ancestors(self, ownership: str): - from tests.unit_tests.core.app.workflow.test_persistence_layer import _make_layer + from tests.unit_tests.services.test_workflow_persistence import _make_layer published: list[object] = [] diff --git a/api/tests/unit_tests/services/test_workflow_run_agg.py b/api/tests/unit_tests/services/test_workflow_run_agg.py index fb7ae09aa02cf1..015548a2fc3c52 100644 --- a/api/tests/unit_tests/services/test_workflow_run_agg.py +++ b/api/tests/unit_tests/services/test_workflow_run_agg.py @@ -129,7 +129,7 @@ def _pipeline_tool_runner( ) session.add(provider) session.flush() - graph = { + graph: dict[str, object] = { "nodes": [ {"id": "start", "data": {"type": "start", "title": "Start", "variables": list[object]()}}, { diff --git a/api/tests/unit_tests/services/tracing/test_recovery.py b/api/tests/unit_tests/services/tracing/test_recovery.py index 91cda1b9d1d60c..719189d33fda21 100644 --- a/api/tests/unit_tests/services/tracing/test_recovery.py +++ b/api/tests/unit_tests/services/tracing/test_recovery.py @@ -21,6 +21,7 @@ from models.enums import ConversationFromSource, CreatorUserRole, MessageStatus, WorkflowRunTriggeredFrom from models.model import App, AppMode, Message from models.workflow import WorkflowRun, WorkflowType +from repositories.app.generation_repository import AppGenerationRepository from services.tracing import recovery from services.tracing import service as service_module from services.tracing.normalization import span_id @@ -430,6 +431,7 @@ def test_stop_save_preserves_worker_usage_committed_after_its_cached_message_rea def test_stopped_runner_hands_off_only_an_observed_runtime_terminal( case: RecoveryCase, monkeypatch: pytest.MonkeyPatch, + app_records: AppGenerationRepository, runtime_outcome: str, final_batch_arrived: bool, snapshot_save_fails: bool, @@ -481,7 +483,7 @@ def unexpected_cancel(*_args: object, **_kwargs: object) -> None: monkeypatch.setattr(client, "cancel_run_and_wait", unexpected_cancel) store = _ExplodingSessionStore() if snapshot_save_fails else _FakeSessionStore() - runner = _runner(client, store) + runner = _runner(client, store, app_records=app_records) run = case.collection.run context = DifyRunContext( tenant_id=run.tenant_id, @@ -584,7 +586,10 @@ def test_missing_runtime_stop_terminal_converges_without_inventing_success( @pytest.mark.parametrize("stop_on_end", [False, True]) def test_stop_during_final_queue_publication_preserves_the_already_observed_success( - case: RecoveryCase, monkeypatch: pytest.MonkeyPatch, stop_on_end: bool + case: RecoveryCase, + monkeypatch: pytest.MonkeyPatch, + app_records: AppGenerationRepository, + stop_on_end: bool, ) -> None: from cachetools import TTLCache from flask import Flask @@ -629,7 +634,7 @@ def test_stop_during_final_queue_publication_preserves_the_already_observed_succ clock = [0.0] queue_manager._stopped_cache = TTLCache(maxsize=1, ttl=1, timer=lambda: clock[0]) store = _FakeSessionStore() - runner = _runner(FakeAgentBackendRunClient(), store) + runner = _runner(FakeAgentBackendRunClient(), store, app_records=app_records) original_publish = queue_manager._publish def publish_after_stop(event: AppQueueEvent, pub_from: PublishFrom) -> None: @@ -681,7 +686,10 @@ def publish_after_stop(event: AppQueueEvent, pub_from: PublishFrom) -> None: @pytest.mark.parametrize("stop_during_cleanup", [False, True]) def test_runtime_cleanup_cancellation_does_not_reclassify_a_business_stream_failure( - case: RecoveryCase, monkeypatch: pytest.MonkeyPatch, stop_during_cleanup: bool + case: RecoveryCase, + monkeypatch: pytest.MonkeyPatch, + app_records: AppGenerationRepository, + stop_during_cleanup: bool, ) -> None: from collections.abc import Iterator from typing import cast @@ -722,7 +730,7 @@ def cancel_then_stop(*_args: object, **_kwargs: object) -> RunEvent: monkeypatch.setattr(client, "cancel_run_and_wait", cancel_then_stop) store = _FakeSessionStore() - runner = _runner(client, store) + runner = _runner(client, store, app_records=app_records) run = case.collection.run context = DifyRunContext( tenant_id=run.tenant_id, From 78e1b0229baa6744bfd0f2653764e64d293511cb Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Mon, 26 Oct 2026 21:00:59 +0800 Subject: [PATCH 06/11] Patch the service request builder in tracing recovery tests --- api/tests/unit_tests/services/tracing/test_recovery.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tests/unit_tests/services/tracing/test_recovery.py b/api/tests/unit_tests/services/tracing/test_recovery.py index 719189d33fda21..a00e7fe7eccf40 100644 --- a/api/tests/unit_tests/services/tracing/test_recovery.py +++ b/api/tests/unit_tests/services/tracing/test_recovery.py @@ -620,7 +620,7 @@ def test_stop_during_final_queue_publication_preserves_the_already_observed_succ base_app_queue_manager.redis_client, "setex", lambda key, _ttl, value: flags.__setitem__(key, value) ) monkeypatch.setattr( - "core.app.apps.agent_app.runtime_request_builder.resolve_model_context_window", lambda **_kwargs: None + "services.app.generation.adapters.agent_request_builder.resolve_model_context_window", lambda **_kwargs: None ) task_id = identifier("stop-at-final-publish") queue_manager = MessageBasedAppQueueManager( From 432b776927fd0ec68f20fc1fc846c442cb6930b3 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:31:24 +0000 Subject: [PATCH 07/11] [autofix.ci] apply automated fixes --- .../generated/api/console/apps/errors.gen.ts | 26 ------------------- .../generated/api/console/errors.gen.ts | 20 -------------- .../generated/api/console/rag/errors.gen.ts | 18 ------------- 3 files changed, 64 deletions(-) diff --git a/packages/contracts/generated/api/console/apps/errors.gen.ts b/packages/contracts/generated/api/console/apps/errors.gen.ts index 1ca81cfa7c000b..4cd4d5e228e674 100644 --- a/packages/contracts/generated/api/console/apps/errors.gen.ts +++ b/packages/contracts/generated/api/console/apps/errors.gen.ts @@ -605,8 +605,6 @@ export const get15Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, } -export const get16Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const post13Errors = { no_file_uploaded: { status: 400, data: zNoFileUploadedError }, too_many_files: { status: 400, data: zTooManyFilesError }, @@ -919,17 +917,10 @@ export const get65Errors = { app_not_found: { status: 404, data: zAppNotFoundErr export const get66Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const get67Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const post44Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const get68Errors = { - app_not_found: { status: 404, data: zAppNotFoundError }, draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError }, } -export const post45Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const post46Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } export const post47Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } @@ -993,10 +984,6 @@ export const post56Errors = { app_not_found: { status: 404, data: zAppNotFoundEr export const post57Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } -export const delete11Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const get72Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const post58Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, rate_limit_error: { status: 429, data: zRateLimitError }, @@ -1025,8 +1012,6 @@ export const get76Errors = { workflow_run_not_found: { status: 404, data: zWorkflowRunNotFoundError }, } -export const get77Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const post59Errors = { app_not_found: { status: 404, data: zAppNotFoundError }, rate_limit_error: { status: 429, data: zRateLimitError }, @@ -1037,18 +1022,7 @@ export const post60Errors = { rate_limit_error: { status: 429, data: zRateLimitError }, } -export const put6Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const delete12Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const get78Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const patch2Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - -export const delete13Errors = { app_not_found: { status: 404, data: zAppNotFoundError } } - export const get79Errors = { - app_not_found: { status: 404, data: zAppNotFoundError }, draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError }, } diff --git a/packages/contracts/generated/api/console/errors.gen.ts b/packages/contracts/generated/api/console/errors.gen.ts index 34e0844610ffc4..e3098084a0a65e 100644 --- a/packages/contracts/generated/api/console/errors.gen.ts +++ b/packages/contracts/generated/api/console/errors.gen.ts @@ -1,11 +1,8 @@ // This file is auto-generated by scripts/add-orpc-error-maps.ts export { delete10Errors as 'DELETE /apps/{app_id}/workflow/comments/{comment_id}' } from './apps/errors.gen.ts' export { delete10Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/research-tasks/{task_id}' } from './knowledge-fs/errors.gen.ts' -export { delete11Errors as 'DELETE /apps/{app_id}/workflows/draft/nodes/{node_id}/variables' } from './apps/errors.gen.ts' export { delete11Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/sources/{source_id}' } from './knowledge-fs/errors.gen.ts' -export { delete12Errors as 'DELETE /apps/{app_id}/workflows/draft/variables/{variable_id}' } from './apps/errors.gen.ts' export { delete12Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}' } from './knowledge-fs/errors.gen.ts' -export { delete13Errors as 'DELETE /apps/{app_id}/workflows/draft/variables' } from './apps/errors.gen.ts' export { delete13Errors as 'DELETE /knowledge-fs/uploads/{upload_id}' } from './knowledge-fs/errors.gen.ts' export { delete14Errors as 'DELETE /apps/{app_id}/workflows/{workflow_id}' } from './apps/errors.gen.ts' export { delete16Errors as 'DELETE /workspaces/current/skills/{skill_id}' } from './workspaces/errors.gen.ts' @@ -14,15 +11,12 @@ export { delete2Errors as 'DELETE /apps/{app_id}/agent/config/skills/{name}' } f export { delete2Errors as 'DELETE /datasets/external-knowledge-api/{external_knowledge_api_id}' } from './datasets/errors.gen.ts' export { delete2Errors as 'DELETE /installed-apps/{installed_app_id}/saved-messages/{message_id}' } from './installed-apps/errors.gen.ts' export { delete2Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/app-bindings/{caller_kind}/{app_id}' } from './knowledge-fs/errors.gen.ts' -export { delete2Errors as 'DELETE /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/variables' } from './rag/errors.gen.ts' export { delete3Errors as 'DELETE /agent/{agent_id}/config/files/{name}' } from './agent/errors.gen.ts' export { delete3Errors as 'DELETE /datasets/{dataset_id}/documents/{document_id}/segments/{segment_id}/child_chunks/{child_chunk_id}' } from './datasets/errors.gen.ts' export { delete3Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/documents/bulk' } from './knowledge-fs/errors.gen.ts' -export { delete3Errors as 'DELETE /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id}' } from './rag/errors.gen.ts' export { delete4Errors as 'DELETE /agent/{agent_id}/config/skills/{name}' } from './agent/errors.gen.ts' export { delete4Errors as 'DELETE /apps/{app_id}/annotations' } from './apps/errors.gen.ts' export { delete4Errors as 'DELETE /knowledge-fs/spaces/{control_space_id}/documents/{document_id}' } from './knowledge-fs/errors.gen.ts' -export { delete4Errors as 'DELETE /rag/pipelines/{pipeline_id}/workflows/draft/variables' } from './rag/errors.gen.ts' export { delete4Errors as 'DELETE /workspaces/current/members/{member_id}' } from './workspaces/errors.gen.ts' export { delete5Errors as 'DELETE /agent/{agent_id}/trace-config' } from './agent/errors.gen.ts' export { delete5Errors as 'DELETE /apps/{app_id}/chat-conversations/{conversation_id}' } from './apps/errors.gen.ts' @@ -63,7 +57,6 @@ export { get13Errors as 'GET /trial-apps/{app_id}/messages/{message_id}/suggeste export { get14Errors as 'GET /agent/{agent_id}/config/skills/{name}/files/content' } from './agent/errors.gen.ts' export { get14Errors as 'GET /apps/{app_id}/agent/config/skills/{name}/inspect' } from './apps/errors.gen.ts' export { get14Errors as 'GET /knowledge-fs/spaces/{control_space_id}/documents/{document_id}/revisions' } from './knowledge-fs/errors.gen.ts' -export { get14Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/nodes/{node_id}/variables' } from './rag/errors.gen.ts' export { get14Errors as 'GET /snippets/{snippet_id}/workflows/draft/variables' } from './snippets/errors.gen.ts' export { get14Errors as 'GET /trial-apps/{app_id}/parameters' } from './trial-apps/errors.gen.ts' export { get15Errors as 'GET /agent/{agent_id}/config/skills/{name}/files/download' } from './agent/errors.gen.ts' @@ -73,19 +66,16 @@ export { get15Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/pre-pr export { get15Errors as 'GET /snippets/{snippet_id}/workflows/draft' } from './snippets/errors.gen.ts' export { get15Errors as 'GET /trial-apps/{app_id}/site' } from './trial-apps/errors.gen.ts' export { get16Errors as 'GET /agent/{agent_id}/config/skills/{name}/files/preview' } from './agent/errors.gen.ts' -export { get16Errors as 'GET /apps/{app_id}/agent/logs' } from './apps/errors.gen.ts' export { get16Errors as 'GET /datasets/{dataset_id}/documents/{document_id}/indexing-estimate' } from './datasets/errors.gen.ts' export { get16Errors as 'GET /knowledge-fs/spaces/{control_space_id}/documents' } from './knowledge-fs/errors.gen.ts' export { get16Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/processing/parameters' } from './rag/errors.gen.ts' export { get16Errors as 'GET /trial-apps/{app_id}/workflows' } from './trial-apps/errors.gen.ts' export { get17Errors as 'GET /agent/{agent_id}/config/skills/{name}/inspect' } from './agent/errors.gen.ts' export { get17Errors as 'GET /knowledge-fs/spaces/{control_space_id}/external-access' } from './knowledge-fs/errors.gen.ts' -export { get17Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/system-variables' } from './rag/errors.gen.ts' export { get17Errors as 'GET /trial-apps/{app_id}' } from './trial-apps/errors.gen.ts' export { get18Errors as 'GET /agent/{agent_id}/config/skills' } from './agent/errors.gen.ts' export { get18Errors as 'GET /datasets/{dataset_id}/documents/{document_id}/notion/sync' } from './datasets/errors.gen.ts' export { get18Errors as 'GET /knowledge-fs/spaces/{control_space_id}/golden-questions' } from './knowledge-fs/errors.gen.ts' -export { get18Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id}' } from './rag/errors.gen.ts' export { get19Errors as 'GET /agent/{agent_id}/log-sources' } from './agent/errors.gen.ts' export { get19Errors as 'GET /knowledge-fs/spaces/{control_space_id}/jobs/{job_id}' } from './knowledge-fs/errors.gen.ts' export { get19Errors as 'GET /rag/pipelines/{pipeline_id}/workflows/draft/variables' } from './rag/errors.gen.ts' @@ -228,7 +218,6 @@ export { get63Errors as 'GET /apps/{app_id}/workflow/statistics/daily-terminals' export { get64Errors as 'GET /apps/{app_id}/workflow/statistics/token-costs' } from './apps/errors.gen.ts' export { get65Errors as 'GET /apps/{app_id}/workflows/default-workflow-block-configs/{block_type}' } from './apps/errors.gen.ts' export { get66Errors as 'GET /apps/{app_id}/workflows/default-workflow-block-configs' } from './apps/errors.gen.ts' -export { get67Errors as 'GET /apps/{app_id}/workflows/draft/conversation-variables' } from './apps/errors.gen.ts' export { get68Errors as 'GET /apps/{app_id}/workflows/draft/environment-variables' } from './apps/errors.gen.ts' export { get69Errors as 'GET /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer/candidates' } from './apps/errors.gen.ts' export { get6Errors as 'GET /agent/{agent_id}/chat-messages' } from './agent/errors.gen.ts' @@ -239,13 +228,10 @@ export { get6Errors as 'GET /rag/pipelines/{pipeline_id}/exports' } from './rag/ export { get6Errors as 'GET /trial-apps/{app_id}/agent/config/skills/{name}/files/content' } from './trial-apps/errors.gen.ts' export { get70Errors as 'GET /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer' } from './apps/errors.gen.ts' export { get71Errors as 'GET /apps/{app_id}/workflows/draft/nodes/{node_id}/last-run' } from './apps/errors.gen.ts' -export { get72Errors as 'GET /apps/{app_id}/workflows/draft/nodes/{node_id}/variables' } from './apps/errors.gen.ts' export { get73Errors as 'GET /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs/events' } from './apps/errors.gen.ts' export { get74Errors as 'GET /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs/{node_id}/{output_name}/preview' } from './apps/errors.gen.ts' export { get75Errors as 'GET /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs/{node_id}' } from './apps/errors.gen.ts' export { get76Errors as 'GET /apps/{app_id}/workflows/draft/runs/{run_id}/node-outputs' } from './apps/errors.gen.ts' -export { get77Errors as 'GET /apps/{app_id}/workflows/draft/system-variables' } from './apps/errors.gen.ts' -export { get78Errors as 'GET /apps/{app_id}/workflows/draft/variables/{variable_id}' } from './apps/errors.gen.ts' export { get79Errors as 'GET /apps/{app_id}/workflows/draft/variables' } from './apps/errors.gen.ts' export { get7Errors as 'GET /account/profile' } from './account/errors.gen.ts' export { get7Errors as 'GET /agent/{agent_id}/composer/candidates' } from './agent/errors.gen.ts' @@ -287,11 +273,9 @@ export { getErrors as 'GET /installed-apps/{installed_app_id}/conversations' } f export { getErrors as 'GET /remote-files/{url}' } from './remote-files/errors.gen.ts' export { getErrors as 'GET /trial-apps/{app_id}/agent-composer' } from './trial-apps/errors.gen.ts' export { getErrors as 'GET /website/crawl/status/{job_id}' } from './website/errors.gen.ts' -export { patch2Errors as 'PATCH /apps/{app_id}/workflows/draft/variables/{variable_id}' } from './apps/errors.gen.ts' export { patch2Errors as 'PATCH /datasets/{dataset_id}/documents/status/{action}/batch' } from './datasets/errors.gen.ts' export { patch2Errors as 'PATCH /installed-apps/{installed_app_id}/conversations/{c_id}/unpin' } from './installed-apps/errors.gen.ts' export { patch2Errors as 'PATCH /knowledge-fs/spaces/{control_space_id}/golden-questions/{question_id}' } from './knowledge-fs/errors.gen.ts' -export { patch2Errors as 'PATCH /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id}' } from './rag/errors.gen.ts' export { patch3Errors as 'PATCH /apps/{app_id}/workflows/{workflow_id}' } from './apps/errors.gen.ts' export { patch3Errors as 'PATCH /datasets/{dataset_id}/documents/{document_id}/processing/pause' } from './datasets/errors.gen.ts' export { patch3Errors as 'PATCH /installed-apps/{installed_app_id}' } from './installed-apps/errors.gen.ts' @@ -452,9 +436,7 @@ export { post42Errors as 'POST /apps/{app_id}/workflow/comments/{comment_id}/res export { post42Errors as 'POST /knowledge-fs/uploads' } from './knowledge-fs/errors.gen.ts' export { post43Errors as 'POST /apps/{app_id}/workflow/comments' } from './apps/errors.gen.ts' export { post43Errors as 'POST /workspaces/current/plugin/upload/github' } from './workspaces/errors.gen.ts' -export { post44Errors as 'POST /apps/{app_id}/workflows/draft/conversation-variables' } from './apps/errors.gen.ts' export { post44Errors as 'POST /workspaces/current/plugin/upload/pkg' } from './workspaces/errors.gen.ts' -export { post45Errors as 'POST /apps/{app_id}/workflows/draft/environment-variables' } from './apps/errors.gen.ts' export { post46Errors as 'POST /apps/{app_id}/workflows/draft/features' } from './apps/errors.gen.ts' export { post47Errors as 'POST /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/delivery-test' } from './apps/errors.gen.ts' export { post48Errors as 'POST /apps/{app_id}/workflows/draft/human-input/nodes/{node_id}/form/preview' } from './apps/errors.gen.ts' @@ -559,10 +541,8 @@ export { put4Errors as 'PUT /apps/{app_id}/workflow/comments/{comment_id}' } fro export { put4Errors as 'PUT /knowledge-fs/spaces/{control_space_id}/sources/{source_id}/sync-policy' } from './knowledge-fs/errors.gen.ts' export { put5Errors as 'PUT /apps/{app_id}/workflows/draft/nodes/{node_id}/agent-composer' } from './apps/errors.gen.ts' export { put5Errors as 'PUT /knowledge-fs/spaces/{control_space_id}/tags' } from './knowledge-fs/errors.gen.ts' -export { put6Errors as 'PUT /apps/{app_id}/workflows/draft/variables/{variable_id}/reset' } from './apps/errors.gen.ts' export { putErrors as 'PUT /agent/{agent_id}/build-draft' } from './agent/errors.gen.ts' export { putErrors as 'PUT /apps/{app_id}/network-access-group' } from './apps/errors.gen.ts' export { putErrors as 'PUT /knowledge-fs/spaces/{control_space_id}/app-bindings' } from './knowledge-fs/errors.gen.ts' -export { putErrors as 'PUT /rag/pipelines/{pipeline_id}/workflows/draft/variables/{variable_id}/reset' } from './rag/errors.gen.ts' export { putErrors as 'PUT /snippets/{snippet_id}/workflows/draft/nodes/{node_id}/agent-composer' } from './snippets/errors.gen.ts' export { putErrors as 'PUT /workspaces/current/agents/{agent_id}/skills' } from './workspaces/errors.gen.ts' diff --git a/packages/contracts/generated/api/console/rag/errors.gen.ts b/packages/contracts/generated/api/console/rag/errors.gen.ts index f87498e70afbc6..501d339d7f2b47 100644 --- a/packages/contracts/generated/api/console/rag/errors.gen.ts +++ b/packages/contracts/generated/api/console/rag/errors.gen.ts @@ -78,7 +78,6 @@ export const post10Errors = { pipeline_not_found: { status: 404, data: zPipeline export const get12Errors = { draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError }, - pipeline_not_found: { status: 404, data: zPipelineNotFoundError }, } export const post11Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } @@ -89,10 +88,6 @@ export const get13Errors = { pipeline_not_found: { status: 404, data: zPipelineN export const post13Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } -export const delete2Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - -export const get14Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - export const get15Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } export const get16Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } @@ -102,21 +97,8 @@ export const post14Errors = { rate_limit_error: { status: 429, data: zRateLimitError }, } -export const get17Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - -export const putErrors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - -export const delete3Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - -export const get18Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - -export const patch2Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - -export const delete4Errors = { pipeline_not_found: { status: 404, data: zPipelineNotFoundError } } - export const get19Errors = { draft_workflow_not_exist: { status: 404, data: zDraftWorkflowNotExistError }, - pipeline_not_found: { status: 404, data: zPipelineNotFoundError }, } export const get20Errors = { From 7362160e99e0b0594bea3aae352bc2facd7eb665 Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Sat, 31 Oct 2026 21:52:05 +0800 Subject: [PATCH 08/11] Inject migrated workflow contexts in HITL reconnect tests --- .../human_input_v2/test_form_delivery_runtime.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/api/tests/unit_tests/services/human_input_v2/test_form_delivery_runtime.py b/api/tests/unit_tests/services/human_input_v2/test_form_delivery_runtime.py index bdc93df83f5f0f..7b34a01df59dfd 100644 --- a/api/tests/unit_tests/services/human_input_v2/test_form_delivery_runtime.py +++ b/api/tests/unit_tests/services/human_input_v2/test_form_delivery_runtime.py @@ -1517,7 +1517,12 @@ def send(candidate: ResendCandidate, *, to: str, subject: str, html: str) -> Non @pytest.mark.usefixtures("providers") @pytest.mark.parametrize("recipient", [Initiator(), OnetimeEmail(email="person@example.com")]) def test_pause_persistence_and_reconnect_preserve_v2_form_and_initiator_only_token( - context: Context, monkeypatch: pytest.MonkeyPatch, recipient: Initiator | OnetimeEmail + context: Context, + monkeypatch: pytest.MonkeyPatch, + recipient: Initiator | OnetimeEmail, + *, + workflow_contexts, + tool_providers, ) -> None: from core.app.entities.queue_entities import QueueWorkflowPausedEvent from core.app.entities.task_entities import ( @@ -1600,7 +1605,7 @@ def reject_live_reload() -> Session: raise AssertionError("Unexpected live form reload") monkeypatch.setattr(session_factory, "create_session", reject_live_reload) - converter = _build_converter() + converter = _build_converter(workflow_contexts=workflow_contexts, tool_providers=tool_providers) converter.workflow_start_to_stream_response( task_id="task", workflow_run_id=_RUN, workflow_id=run.workflow_id, reason=WorkflowStartReason.INITIAL ) From eff3602a95273b103a90c1692823f89a1c5d0793 Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Sat, 31 Oct 2026 22:21:46 +0800 Subject: [PATCH 09/11] Pass typed converter dependencies in HITL v2 tests --- .../nodes/human_input_v2/test_node.py | 21 +++++++++++++------ .../test_form_delivery_runtime.py | 6 ++++-- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/api/tests/unit_tests/core/workflow/nodes/human_input_v2/test_node.py b/api/tests/unit_tests/core/workflow/nodes/human_input_v2/test_node.py index 9b9199c67494b4..0ba57ededc5872 100644 --- a/api/tests/unit_tests/core/workflow/nodes/human_input_v2/test_node.py +++ b/api/tests/unit_tests/core/workflow/nodes/human_input_v2/test_node.py @@ -12,7 +12,6 @@ QueueHumanInputFormTimeoutEvent, QueueNodeSucceededEvent, ) -from enums.human_input import ButtonStyle from core.human_input_v2.resolved_form import ( FileInput, FileListInput, @@ -33,7 +32,7 @@ from core.workflow.nodes.human_input_v2.node import HumanInputNode from core.workflow.nodes.human_input_v2.runtime import HumanInputDeliveryError, HumanInputRuntime, PreparedForm from core.workflow.workflow_entry import WorkflowEntry -from enums.human_input import HumanInputFormKind, HumanInputFormStatus, ValueSourceType +from enums.human_input import ButtonStyle, HumanInputFormKind, HumanInputFormStatus, ValueSourceType from graphon.engine_events import ( EngineEvent, NodeRunFailedEvent, @@ -56,6 +55,8 @@ StringSource, ) from repositories.human_input_v2.form_repository import Form, FormSubmission +from services.tools.provider_queries import ToolProviderIcons +from services.workflow.execution.ports import ExecutionContexts from tests.unit_tests.core.app.apps.common.test_workflow_response_converter import ( _workflow_response_converter_human_input_build_converter as _build_converter, ) @@ -270,7 +271,13 @@ def test_reentry_consumes_the_new_persisted_outcome(form: Form) -> None: @pytest.mark.parametrize("status", [HumanInputFormStatus.SUBMITTED, HumanInputFormStatus.TIMEOUT]) -def test_actual_graph_queue_response_path_preserves_order_and_fields(form: Form, status: HumanInputFormStatus) -> None: +def test_actual_graph_queue_response_path_preserves_order_and_fields( + form: Form, + status: HumanInputFormStatus, + *, + workflow_contexts: ExecutionContexts, + tool_providers: ToolProviderIcons, +) -> None: form = submit(form) if status == HumanInputFormStatus.SUBMITTED else replace(form, status=status) node, _ = build_node(form) runner = create_autospec(WorkflowBasedAppRunner, instance=True) @@ -279,7 +286,7 @@ def test_actual_graph_queue_response_path_preserves_order_and_fields(form: Form, WorkflowBasedAppRunner.handle_event(runner, entry, event) queue_events = [call.args[0] for call in runner._publish_event.call_args_list] assert isinstance(queue_events[1], QueueNodeSucceededEvent) - converter = _build_converter() + converter = _build_converter(workflow_contexts=workflow_contexts, tool_providers=tool_providers) converter.workflow_start_to_stream_response( task_id="task-1", workflow_run_id="run-1", workflow_id="wf-1", reason=WorkflowStartReason.INITIAL ) @@ -336,7 +343,9 @@ def test_dify_projection_serializes_the_runtime_token_without_a_graph_reason(for } -def test_file_segments_survive_node_graph_queue_and_response(form: Form) -> None: +def test_file_segments_survive_node_graph_queue_and_response( + form: Form, *, workflow_contexts: ExecutionContexts, tool_providers: ToolProviderIcons +) -> None: attachment = File( file_id="file-1", file_type=FileType.DOCUMENT, @@ -377,7 +386,7 @@ def test_file_segments_survive_node_graph_queue_and_response(form: Form) -> None queue_event = runner._publish_event.call_args.args[0] assert isinstance(queue_event.submitted_data["attachment"], FileSegment) assert isinstance(queue_event.submitted_data["files"], ArrayFileSegment) - converter = _build_converter() + converter = _build_converter(workflow_contexts=workflow_contexts, tool_providers=tool_providers) converter.workflow_start_to_stream_response( task_id="task-1", workflow_run_id="run-1", workflow_id="wf-1", reason=WorkflowStartReason.INITIAL ) diff --git a/api/tests/unit_tests/services/human_input_v2/test_form_delivery_runtime.py b/api/tests/unit_tests/services/human_input_v2/test_form_delivery_runtime.py index 7b34a01df59dfd..13c51405501a59 100644 --- a/api/tests/unit_tests/services/human_input_v2/test_form_delivery_runtime.py +++ b/api/tests/unit_tests/services/human_input_v2/test_form_delivery_runtime.py @@ -79,6 +79,8 @@ FormExecutionContext, HumanInputFormService, ) +from services.tools.provider_queries import ToolProviderIcons +from services.workflow.execution.ports import ExecutionContexts _TENANT = TenantId("00000000-0000-0000-0000-000000000001") _APP = "00000000-0000-0000-0000-000000000002" @@ -1521,8 +1523,8 @@ def test_pause_persistence_and_reconnect_preserve_v2_form_and_initiator_only_tok monkeypatch: pytest.MonkeyPatch, recipient: Initiator | OnetimeEmail, *, - workflow_contexts, - tool_providers, + workflow_contexts: ExecutionContexts, + tool_providers: ToolProviderIcons, ) -> None: from core.app.entities.queue_entities import QueueWorkflowPausedEvent from core.app.entities.task_entities import ( From bc7074d7799041c480a7e3c205a28dd467d03d85 Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Sat, 31 Oct 2026 22:51:07 +0800 Subject: [PATCH 10/11] Format migrated test fixtures --- .../core/app/apps/workflow/test_app_generator.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/api/tests/unit_tests/core/app/apps/workflow/test_app_generator.py b/api/tests/unit_tests/core/app/apps/workflow/test_app_generator.py index bd675fb5ffaaaa..8f674f46c38679 100644 --- a/api/tests/unit_tests/core/app/apps/workflow/test_app_generator.py +++ b/api/tests/unit_tests/core/app/apps/workflow/test_app_generator.py @@ -776,6 +776,7 @@ def inspect_execution( graph_runtime_state=runtime_state, ) assert observed == [(user.id, expected_user_from, expected_system_user_id)] + class TestWorkflowAppGeneratorValidation: def test_generate_stream_joins_worker_after_response_exhaustion( self, @@ -886,9 +887,7 @@ def test_single_loop_generate_validates_args(self, *, workflow_runtime: Workflow workflow=_app_generator_extra_workflow(), node_id="", user=_end_user(), - execution_identity=WorkflowExecutionIdentity( - user_id=_end_user().id, user_from=UserFrom.END_USER - ), + execution_identity=WorkflowExecutionIdentity(user_id=_end_user().id, user_from=UserFrom.END_USER), args={"inputs": {}}, streaming=False, ) @@ -991,9 +990,7 @@ def test_single_loop_generate_includes_trace_session_id_in_extras( workflow=_app_generator_extra_workflow(), node_id="node", user=_end_user(), - execution_identity=WorkflowExecutionIdentity( - user_id=_end_user().id, user_from=UserFrom.END_USER - ), + execution_identity=WorkflowExecutionIdentity(user_id=_end_user().id, user_from=UserFrom.END_USER), args={"inputs": None}, streaming=False, ) From 51391e29bf232f28500d1a7160a362baf409e63a Mon Sep 17 00:00:00 2001 From: Byron Wang Date: Sat, 31 Oct 2026 22:54:15 +0800 Subject: [PATCH 11/11] Use workflow identity service at invocation boundary --- .../workflow/execution/adapters/workflow/app_generator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/services/workflow/execution/adapters/workflow/app_generator.py b/api/services/workflow/execution/adapters/workflow/app_generator.py index 71a0043fbc97b9..14e0a92fadf3b6 100644 --- a/api/services/workflow/execution/adapters/workflow/app_generator.py +++ b/api/services/workflow/execution/adapters/workflow/app_generator.py @@ -42,7 +42,6 @@ from models.enums import WorkflowRunTriggeredFrom from models.model import App, EndUser from models.workflow import WorkflowNodeExecutionTriggeredFrom -from repositories.entities.workflow_execution import WorkflowExecutionIdentity from services.app.file_input_defaults import grant_file_default_access from services.app.generation.input_adapter import AppInputAdapter from services.workflow.contracts import WorkflowSnapshot @@ -60,6 +59,7 @@ if TYPE_CHECKING: from services.workflow.execution.contracts import WorkflowRunDriver + from services.workflow_execution_identity import WorkflowExecutionIdentity logger = logging.getLogger(__name__)