test: migrate app runtime sessions and ORM models to SQLite (#40590)

This commit is contained in:
Asuka Minato 2026-08-18 12:27:32 +00:00 committed by GitHub
parent f2dfa462e1
commit 4f81005662
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 343 additions and 158 deletions

View File

@ -3,9 +3,11 @@ from unittest.mock import MagicMock
import pytest
from pytest_mock import MockerFixture
from sqlalchemy.orm import Session
from core.app.app_config.easy_ui_based_app.dataset.manager import DatasetConfigManager
from core.entities.agent_entities import PlanningStrategy
from models.dataset import Dataset
from models.model import AppMode
# ==============================
@ -33,12 +35,16 @@ def base_config(valid_uuid):
@pytest.fixture
def mock_dataset_service(mocker: MockerFixture, valid_uuid):
mock_dataset = MagicMock()
mock_dataset.tenant_id = "tenant1"
dataset = Dataset(
id=valid_uuid,
tenant_id="tenant1",
name="Test Dataset",
created_by="account-1",
)
mocker.patch(
"core.app.app_config.easy_ui_based_app.dataset.manager.DatasetService.get_dataset",
return_value=mock_dataset,
return_value=dataset,
)
@ -206,19 +212,21 @@ class TestDatasetConfigManagerConvert:
class TestValidateAndSetDefaults:
def test_validate_sets_defaults(self):
def test_validate_sets_defaults(self, unbound_session: Session):
config = {}
updated, fields = DatasetConfigManager.validate_and_set_defaults("tenant1", AppMode.CHAT, config, MagicMock())
updated, fields = DatasetConfigManager.validate_and_set_defaults(
"tenant1", AppMode.CHAT, config, unbound_session
)
assert "dataset_configs" in updated
assert updated["dataset_configs"]["retrieval_model"] == "single"
assert isinstance(fields, list)
def test_validate_raises_when_dataset_configs_not_dict(self):
def test_validate_raises_when_dataset_configs_not_dict(self, unbound_session: Session):
config = {"dataset_configs": "invalid"}
with pytest.raises(AttributeError):
DatasetConfigManager.validate_and_set_defaults("tenant1", AppMode.CHAT, config, MagicMock())
DatasetConfigManager.validate_and_set_defaults("tenant1", AppMode.CHAT, config, unbound_session)
def test_validate_requires_query_variable_in_completion_mode(self, valid_uuid):
def test_validate_requires_query_variable_in_completion_mode(self, valid_uuid, unbound_session: Session):
config = {
"dataset_configs": {
"datasets": {
@ -228,7 +236,7 @@ class TestValidateAndSetDefaults:
}
}
with pytest.raises(ValueError):
DatasetConfigManager.validate_and_set_defaults("tenant1", AppMode.COMPLETION, config, MagicMock())
DatasetConfigManager.validate_and_set_defaults("tenant1", AppMode.COMPLETION, config, unbound_session)
# ==============================
@ -237,37 +245,37 @@ class TestValidateAndSetDefaults:
class TestExtractDatasetConfig:
def test_extract_sets_defaults(self):
def test_extract_sets_defaults(self, unbound_session: Session):
config = {}
result = DatasetConfigManager.extract_dataset_config_for_legacy_compatibility(
"tenant1", AppMode.CHAT, config, MagicMock()
"tenant1", AppMode.CHAT, config, unbound_session
)
assert "agent_mode" in result
assert result["agent_mode"]["enabled"] is False
assert result["agent_mode"]["tools"] == []
def test_extract_invalid_agent_mode_type(self):
def test_extract_invalid_agent_mode_type(self, unbound_session: Session):
config = {"agent_mode": "invalid"}
with pytest.raises(ValueError):
DatasetConfigManager.extract_dataset_config_for_legacy_compatibility(
"tenant1", AppMode.CHAT, config, MagicMock()
"tenant1", AppMode.CHAT, config, unbound_session
)
def test_extract_invalid_enabled_type(self):
def test_extract_invalid_enabled_type(self, unbound_session: Session):
config = {"agent_mode": {"enabled": "yes"}}
with pytest.raises(ValueError):
DatasetConfigManager.extract_dataset_config_for_legacy_compatibility(
"tenant1", AppMode.CHAT, config, MagicMock()
"tenant1", AppMode.CHAT, config, unbound_session
)
def test_extract_invalid_tools_type(self):
def test_extract_invalid_tools_type(self, unbound_session: Session):
config = {"agent_mode": {"enabled": True, "tools": "invalid"}}
with pytest.raises(ValueError):
DatasetConfigManager.extract_dataset_config_for_legacy_compatibility(
"tenant1", AppMode.CHAT, config, MagicMock()
"tenant1", AppMode.CHAT, config, unbound_session
)
def test_extract_invalid_uuid(self, mocker: MockerFixture):
def test_extract_invalid_uuid(self, mocker: MockerFixture, unbound_session: Session):
invalid_uuid = "not-a-uuid"
config = {
"agent_mode": {
@ -278,10 +286,10 @@ class TestExtractDatasetConfig:
}
with pytest.raises(ValueError):
DatasetConfigManager.extract_dataset_config_for_legacy_compatibility(
"tenant1", AppMode.CHAT, config, MagicMock()
"tenant1", AppMode.CHAT, config, unbound_session
)
def test_extract_dataset_not_exists(self, valid_uuid, mocker: MockerFixture):
def test_extract_dataset_not_exists(self, valid_uuid, mocker: MockerFixture, unbound_session: Session):
mocker.patch(
"core.app.app_config.easy_ui_based_app.dataset.manager.DatasetService.get_dataset",
return_value=None,
@ -295,7 +303,7 @@ class TestExtractDatasetConfig:
}
with pytest.raises(ValueError):
DatasetConfigManager.extract_dataset_config_for_legacy_compatibility(
"tenant1", AppMode.CHAT, config, MagicMock()
"tenant1", AppMode.CHAT, config, unbound_session
)
@ -305,31 +313,41 @@ class TestExtractDatasetConfig:
class TestIsDatasetExists:
def test_dataset_exists_true(self, mocker: MockerFixture, valid_uuid):
mock_dataset = MagicMock()
mock_dataset.tenant_id = "tenant1"
def test_dataset_exists_true(self, mocker: MockerFixture, valid_uuid, unbound_session: Session):
dataset = Dataset(
id=valid_uuid,
tenant_id="tenant1",
name="Test Dataset",
created_by="account-1",
)
mocker.patch(
"core.app.app_config.easy_ui_based_app.dataset.manager.DatasetService.get_dataset",
return_value=mock_dataset,
return_value=dataset,
)
assert DatasetConfigManager.is_dataset_exists("tenant1", valid_uuid, MagicMock())
assert DatasetConfigManager.is_dataset_exists("tenant1", valid_uuid, unbound_session)
def test_dataset_exists_false_when_not_found(self, mocker: MockerFixture, valid_uuid):
def test_dataset_exists_false_when_not_found(self, mocker: MockerFixture, valid_uuid, unbound_session: Session):
mocker.patch(
"core.app.app_config.easy_ui_based_app.dataset.manager.DatasetService.get_dataset",
return_value=None,
)
assert not DatasetConfigManager.is_dataset_exists("tenant1", valid_uuid, MagicMock())
assert not DatasetConfigManager.is_dataset_exists("tenant1", valid_uuid, unbound_session)
def test_dataset_exists_false_when_tenant_mismatch(self, mocker: MockerFixture, valid_uuid):
mock_dataset = MagicMock()
mock_dataset.tenant_id = "other"
def test_dataset_exists_false_when_tenant_mismatch(
self, mocker: MockerFixture, valid_uuid, unbound_session: Session
):
dataset = Dataset(
id=valid_uuid,
tenant_id="other",
name="Other Tenant Dataset",
created_by="account-2",
)
mocker.patch(
"core.app.app_config.easy_ui_based_app.dataset.manager.DatasetService.get_dataset",
return_value=mock_dataset,
return_value=dataset,
)
assert not DatasetConfigManager.is_dataset_exists("tenant1", valid_uuid, MagicMock())
assert not DatasetConfigManager.is_dataset_exists("tenant1", valid_uuid, unbound_session)
# ==============================
@ -338,7 +356,7 @@ class TestIsDatasetExists:
class TestExtractDatasetConfigForLegacyCompatibility:
def test_skips_empty_tool_entry(self):
def test_skips_empty_tool_entry(self, unbound_session: Session):
# A malformed empty tool dict in agent_mode.tools must be skipped, not
# crash with `IndexError` on `list(tool.keys())[0]`. The sibling
# convert() already guards this with `if len(tool) == 1`.
@ -351,7 +369,7 @@ class TestExtractDatasetConfigForLegacyCompatibility:
}
result = DatasetConfigManager.extract_dataset_config_for_legacy_compatibility(
"tenant1", AppMode.CHAT, config, MagicMock()
"tenant1", AppMode.CHAT, config, unbound_session
)
assert result["agent_mode"]["tools"] == [{}]

View File

@ -1,14 +1,32 @@
from types import SimpleNamespace
import json
from unittest.mock import patch
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
from models.model import AppMode
from models.model import App, AppMode
from models.workflow import Workflow, WorkflowType
def _app() -> App:
return App(id="app-1", tenant_id="tenant-1", name="Advanced Chat App", mode=AppMode.ADVANCED_CHAT)
def _workflow() -> Workflow:
return Workflow(
id="wf-1",
tenant_id="tenant-1",
app_id="app-1",
type=WorkflowType.CHAT,
version=Workflow.VERSION_DRAFT,
graph="{}",
features=json.dumps({}),
created_by="account-1",
)
class TestAdvancedChatAppConfigManager:
def test_get_app_config(self):
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.ADVANCED_CHAT.value)
workflow = SimpleNamespace(id="wf-1", features_dict={})
app_model = _app()
workflow = _workflow()
with (
patch(

View File

@ -3,10 +3,11 @@ app_model_config-shaped dict bridge that lets an Agent App ride the chat pipelin
from __future__ import annotations
from types import SimpleNamespace
import json
from core.app.apps.agent_app.app_config_manager import AgentAppConfigManager
from models.agent_config_entities import AgentSoulConfig
from models.model import App, AppMode, AppModelConfig
def _soul() -> AgentSoulConfig:
@ -27,6 +28,13 @@ def _soul() -> AgentSoulConfig:
)
def _app_model_config(**values: object) -> AppModelConfig:
config = AppModelConfig(app_id="app-1")
for key, value in values.items():
setattr(config, key, json.dumps(value) if isinstance(value, (dict, list)) else value)
return config
def test_model_and_prompt_come_from_soul():
d = AgentAppConfigManager._synthesize_config_dict(_soul(), None, annotation_reply=None)
assert d["model"] == {
@ -44,17 +52,15 @@ def test_model_and_prompt_come_from_soul():
def test_feature_flags_come_from_app_model_config_when_present():
# Q3: opener/follow-up/etc. live on app_model_config; model/prompt stay from Soul.
fake_amc = SimpleNamespace(
to_dict=lambda **_: {
"opening_statement": "Hi, I'm Iris.",
"suggested_questions_after_answer": {"enabled": True},
"model": {"provider": "should-be-overridden", "name": "old"},
"pre_prompt": "old prompt",
}
app_model_config = _app_model_config(
opening_statement="Hi, I'm Iris.",
suggested_questions_after_answer={"enabled": True},
model={"provider": "should-be-overridden", "name": "old"},
pre_prompt="old prompt",
)
d = AgentAppConfigManager._synthesize_config_dict(
_soul(),
fake_amc,
app_model_config,
annotation_reply={"enabled": False}, # type: ignore[arg-type]
)
# feature flags preserved
@ -80,18 +86,16 @@ def test_missing_soul_model_leaves_no_model_key():
def test_soul_file_upload_overrides_legacy_app_model_config():
fake_amc = SimpleNamespace(
to_dict=lambda **_: {
"file_upload": {
"enabled": False,
"image": {"enabled": False},
},
}
app_model_config = _app_model_config(
file_upload={
"enabled": False,
"image": {"enabled": False},
},
)
d = AgentAppConfigManager._synthesize_config_dict(
AgentSoulConfig(),
fake_amc,
app_model_config,
annotation_reply={"enabled": False}, # type: ignore[arg-type]
)
@ -115,13 +119,14 @@ def test_prompt_type_defaults_to_simple():
def test_get_app_config_has_null_model_config_id_without_legacy_row():
# An Agent App has no app_model_config row; the conversation's
# app_model_config_id (a UUID column) must be NULL, not "".
app_model = SimpleNamespace(
app_model = App(
tenant_id="11111111-1111-1111-1111-111111111111",
id="22222222-2222-2222-2222-222222222222",
mode="agent",
name="Agent App",
mode=AppMode.AGENT_CHAT,
)
app_config = AgentAppConfigManager.get_app_config(
app_model=app_model, # type: ignore[arg-type]
app_model=app_model,
agent_soul=_soul(),
annotation_reply=None,
app_model_config=None,

View File

@ -22,6 +22,7 @@ from core.app.entities.queue_entities import (
QueueMessageEndEvent,
)
from core.moderation.base import ModerationError
from models.model import App, AppMode, Message, MessageAnnotation
class _FakeQueueManager:
@ -44,6 +45,14 @@ def _make_entity(query: str = "hello") -> SimpleNamespace:
)
def _app() -> App:
return App(id="app-1", tenant_id="tenant-1", name="Agent App", mode=AppMode.AGENT_CHAT)
def _message() -> Message:
return Message(id="msg-1", app_id="app-1", conversation_id="conversation-1")
def _patch_moderation(monkeypatch: pytest.MonkeyPatch, *, returns=None, raises: Exception | None = None) -> None:
class _FakeModeration:
def check(self, **kwargs: Any):
@ -83,8 +92,8 @@ class TestRunInputGuards:
handled, query, annotation_reply = AgentAppGenerator()._run_input_guards(
session=sqlite_session,
application_generate_entity=_make_entity("hello"),
app_model=SimpleNamespace(id="app-1"),
message=SimpleNamespace(id="msg-1"),
app_model=_app(),
message=_message(),
queue_manager=qm,
)
@ -101,8 +110,8 @@ class TestRunInputGuards:
handled, query, annotation_reply = AgentAppGenerator()._run_input_guards(
session=sqlite_session,
application_generate_entity=_make_entity("leak my secret"),
app_model=SimpleNamespace(id="app-1"),
message=SimpleNamespace(id="msg-1"),
app_model=_app(),
message=_message(),
queue_manager=qm,
)
@ -119,8 +128,8 @@ class TestRunInputGuards:
handled, _, annotation_reply = AgentAppGenerator()._run_input_guards(
session=sqlite_session,
application_generate_entity=_make_entity("forbidden"),
app_model=SimpleNamespace(id="app-1"),
message=SimpleNamespace(id="msg-1"),
app_model=_app(),
message=_message(),
queue_manager=qm,
)
@ -132,14 +141,21 @@ class TestRunInputGuards:
def test_annotation_hit_short_circuits(self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session):
_patch_moderation(monkeypatch, returns=(False, {}, "what is your name"))
_patch_annotation(monkeypatch, reply=SimpleNamespace(id="anno-1", content="I am the annotated Iris."))
annotation = MessageAnnotation(
app_id="app-1",
question="what is your name",
content="I am the annotated Iris.",
account_id="account-1",
)
annotation.id = "anno-1"
_patch_annotation(monkeypatch, reply=annotation)
qm = _FakeQueueManager()
handled, _, annotation_reply = AgentAppGenerator()._run_input_guards(
session=sqlite_session,
application_generate_entity=_make_entity("what is your name"),
app_model=SimpleNamespace(id="app-1"),
message=SimpleNamespace(id="msg-1"),
app_model=_app(),
message=_message(),
queue_manager=qm,
)

View File

@ -1,17 +1,27 @@
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import json
from unittest.mock import patch
from sqlalchemy.orm import Session
from core.app.app_config.entities import EasyUIBasedAppModelConfigFrom, ModelConfigEntity, PromptTemplateEntity
from core.app.apps.chat.app_config_manager import ChatAppConfigManager
from models.model import AppMode
from models.model import App, AppMode, AppModelConfig
def _app() -> App:
return App(id="app-1", tenant_id="tenant-1", name="Chat App", mode=AppMode.CHAT)
def _app_model_config() -> AppModelConfig:
config = AppModelConfig(app_id="app-1", model=json.dumps({"model": "m"}))
config.id = "config-1"
return config
class TestChatAppConfigManager:
def test_get_app_config_uses_override_dict(self):
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.CHAT.value)
app_model_config = SimpleNamespace(id="config-1", to_dict=lambda: {"model": "m"})
app_model = _app()
app_model_config = _app_model_config()
override = {"model": "override"}
model_entity = ModelConfigEntity(provider="p", model="m")
@ -45,11 +55,8 @@ class TestChatAppConfigManager:
assert app_config.app_mode == AppMode.CHAT
def test_get_app_config_uses_injected_annotation_reply(self):
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.CHAT.value)
app_model_config = SimpleNamespace(
id="config-1",
to_dict=MagicMock(return_value={"model": "m"}),
)
app_model = _app()
app_model_config = _app_model_config()
annotation_reply = {"enabled": False}
model_entity = ModelConfigEntity(provider="p", model="m")
@ -59,6 +66,7 @@ class TestChatAppConfigManager:
)
with (
patch.object(app_model_config, "to_dict", wraps=app_model_config.to_dict) as to_dict,
patch("core.app.apps.chat.app_config_manager.ModelConfigManager.convert", return_value=model_entity),
patch(
"core.app.apps.chat.app_config_manager.PromptTemplateConfigManager.convert", return_value=prompt_entity
@ -76,7 +84,7 @@ class TestChatAppConfigManager:
annotation_reply=annotation_reply,
)
app_model_config.to_dict.assert_called_once_with(annotation_reply=annotation_reply)
to_dict.assert_called_once_with(annotation_reply=annotation_reply)
def test_config_validate_filters_related_keys(self, unbound_session: Session):
config = {"extra": 1}

View File

@ -8,6 +8,7 @@ from core.workflow.system_variables import build_system_variables
from graphon.entities import WorkflowStartReason
from graphon.runtime import GraphRuntimeState, VariablePool
from graphon.variables.segments import StringSegment
from models.account import Account
def _build_converter():
@ -28,7 +29,8 @@ def _build_converter():
workflow_execution_id="run-1",
call_depth=0,
)
account = SimpleNamespace(id="acc-1", name="tester", email="tester@example.com")
account = Account(name="tester", email="tester@example.com")
account.id = "acc-1"
return WorkflowResponseConverter(
application_generate_entity=app_entity,
user=account,

View File

@ -5,6 +5,7 @@ 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
def _build_converter() -> WorkflowResponseConverter:
@ -26,7 +27,8 @@ def _build_converter() -> WorkflowResponseConverter:
workflow_execution_id="run-1",
call_depth=0,
)
account = SimpleNamespace(id="acc-1", name="tester", email="tester@example.com")
account = Account(name="tester", email="tester@example.com")
account.id = "acc-1"
return WorkflowResponseConverter(
application_generate_entity=app_entity,
user=account,

View File

@ -15,7 +15,8 @@ from core.app.app_config.entities import (
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.model import AppMode
from models.account import Account
from models.model import App, AppMode, Conversation, Message
from services.errors.app_model_config import AppModelConfigBrokenError
@ -45,6 +46,22 @@ class DummyCompletionGenerateEntity:
self.model_conf = DummyModelConf()
def _app(*, app_id: str = "app") -> App:
return App(
id=app_id,
tenant_id="tenant-id",
name="Message App",
mode=AppMode.CHAT,
app_model_config_id=None,
)
def _account() -> Account:
account = Account(name="Message User", email="message-user@example.com")
account.id = "user-id"
return account
def _make_app_config(app_mode: AppMode) -> EasyUIBasedAppConfig:
return EasyUIBasedAppConfig(
tenant_id="tenant-id",
@ -138,24 +155,24 @@ class TestMessageBasedAppGeneratorExtras:
generator._handle_response(
application_generate_entity=_make_chat_generate_entity(_make_app_config(AppMode.CHAT)),
queue_manager=SimpleNamespace(),
conversation=SimpleNamespace(id="conv"),
message=SimpleNamespace(id="msg"),
user=SimpleNamespace(),
conversation=Conversation(id="conv", app_id="app"),
message=Message(id="msg", app_id="app", conversation_id="conv"),
user=_account(),
stream=False,
)
def test_get_app_model_config_requires_valid_config(self, sqlite_session: Session):
generator = MessageBasedAppGenerator()
app_model = SimpleNamespace(id="app", app_model_config_id=None, app_model_config=None)
app_model = _app()
session = sqlite_session
with pytest.raises(AppModelConfigBrokenError):
generator._get_app_model_config(app_model, conversation=None, session=session)
conversation = SimpleNamespace(app_model_config_id="missing-id")
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=SimpleNamespace(id="app"),
app_model=_app(),
conversation=conversation,
session=session,
)

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import json
from datetime import UTC, datetime
from types import SimpleNamespace
@ -48,6 +49,22 @@ from graphon.node_events import NodeRunResult
from graphon.runtime import GraphRuntimeState, VariablePool
from graphon.variables.segments import StringSegment
from graphon.variables.variables import StringVariable
from models.workflow import Workflow, WorkflowType
def _workflow(graph: dict[str, object] | None = None) -> Workflow:
return Workflow.new(
tenant_id="tenant",
app_id="app",
type=WorkflowType.WORKFLOW,
version=Workflow.VERSION_DRAFT,
graph=json.dumps(graph or {}, default=str),
features="{}",
created_by="account",
environment_variables=[],
conversation_variables=[],
rag_pipeline_variables=[],
)
class TestWorkflowBasedAppRunner:
@ -122,7 +139,7 @@ class TestWorkflowBasedAppRunner:
def test_prepare_single_node_execution_requires_run(self):
runner = WorkflowBasedAppRunner(queue_manager=SimpleNamespace(), app_id="app")
workflow = SimpleNamespace(environment_variables=[], graph_dict={})
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")
@ -138,7 +155,8 @@ class TestWorkflowBasedAppRunner:
"nodes": [{"id": "node-1", "data": {"type": "start", "version": "1"}}],
"edges": [],
}
workflow = SimpleNamespace(tenant_id="tenant", id="workflow", graph_dict=graph_config)
workflow = _workflow(graph_config)
workflow.id = "workflow"
monkeypatch.setattr(
"core.app.apps.workflow_app_runner.Graph.init",
@ -191,7 +209,8 @@ class TestWorkflowBasedAppRunner:
"nodes": [{"id": "node-1", "data": {"type": "start", "version": "1"}}],
"edges": [],
}
workflow = SimpleNamespace(tenant_id="tenant", id="workflow", graph_dict=graph_config)
workflow = _workflow(graph_config)
workflow.id = "workflow"
captured = {}
def fake_from_graph_init_context(**kwargs):
@ -256,10 +275,8 @@ class TestWorkflowBasedAppRunner:
start_at=0.0,
)
workflow = SimpleNamespace(
tenant_id="tenant",
id="workflow",
graph_dict={
workflow = _workflow(
{
"nodes": [
{"id": "loop-node", "data": {"type": "loop", "version": "1", "title": "Loop"}},
{
@ -273,8 +290,9 @@ class TestWorkflowBasedAppRunner:
},
],
"edges": [],
},
}
)
workflow.id = "workflow"
class _LoopNodeCls:
@staticmethod

View File

@ -1,3 +1,4 @@
import json
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import MagicMock
@ -26,6 +27,7 @@ from graphon.graph_events import GraphRunPausedEvent
from graphon.runtime import GraphRuntimeState, VariablePool
from models.account import Account
from models.human_input import HumanInputForm, HumanInputFormRecipient, RecipientType
from models.workflow import Workflow, WorkflowType
class _RecordingWorkflowAppRunner(WorkflowAppRunner):
@ -92,12 +94,19 @@ def _build_runner():
workflow_execution_id="run-id",
user_id="user-id",
)
workflow = SimpleNamespace(
graph_dict={},
workflow = Workflow.new(
tenant_id="tenant-id",
environment_variables={},
id="workflow-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,

View File

@ -1,14 +1,32 @@
from types import SimpleNamespace
import json
from unittest.mock import patch
from core.app.apps.workflow.app_config_manager import WorkflowAppConfigManager
from models.model import AppMode
from models.model import App, AppMode
from models.workflow import Workflow, WorkflowType
def _app() -> App:
return App(id="app-1", tenant_id="tenant-1", name="Workflow App", mode=AppMode.WORKFLOW)
def _workflow() -> Workflow:
return Workflow(
id="wf-1",
tenant_id="tenant-1",
app_id="app-1",
type=WorkflowType.WORKFLOW,
version=Workflow.VERSION_DRAFT,
graph="{}",
features=json.dumps({}),
created_by="account-1",
)
class TestWorkflowAppConfigManager:
def test_get_app_config(self):
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode=AppMode.WORKFLOW)
workflow = SimpleNamespace(id="wf-1", features_dict={})
app_model = _app()
workflow = _workflow()
with (
patch(

View File

@ -1,7 +1,9 @@
import json
import time
from contextlib import contextmanager
from unittest.mock import MagicMock
from sqlalchemy.orm import Session, sessionmaker
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
@ -12,6 +14,7 @@ from graphon.entities import WorkflowStartReason
from graphon.runtime import GraphRuntimeState
from models.account import Account
from models.model import AppMode
from models.workflow import Workflow, WorkflowType
from tests.workflow_test_utils import build_test_variable_pool
@ -42,18 +45,20 @@ def _build_runtime_state(run_id: str) -> GraphRuntimeState:
return GraphRuntimeState(variable_pool=variable_pool, start_at=time.perf_counter())
@contextmanager
def _noop_session():
yield MagicMock()
def _build_pipeline(run_id: str) -> WorkflowAppGenerateTaskPipeline:
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 = MagicMock()
workflow.id = "workflow-id"
workflow.features_dict = {}
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",
)
user = Account(name="user", email="user@example.com")
pipeline = WorkflowAppGenerateTaskPipeline(
application_generate_entity=_build_generate_entity(run_id),
@ -63,13 +68,13 @@ def _build_pipeline(run_id: str) -> WorkflowAppGenerateTaskPipeline:
stream=False,
draft_var_saver_factory=MagicMock(),
)
pipeline._database_session = _noop_session
pipeline._database_session = unbound_session_factory
return pipeline
def test_workflow_app_log_saved_only_on_initial_start() -> None:
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)
pipeline = _build_pipeline(run_id, unbound_session_factory)
pipeline._save_workflow_app_log = MagicMock()
event = QueueWorkflowStartedEvent(reason=WorkflowStartReason.INITIAL)
@ -81,9 +86,9 @@ def test_workflow_app_log_saved_only_on_initial_start() -> None:
assert pipeline._workflow_execution_id == run_id
def test_workflow_app_log_skipped_on_resumption_start() -> None:
def test_workflow_app_log_skipped_on_resumption_start(unbound_session_factory: sessionmaker[Session]) -> None:
run_id = "run-resume"
pipeline = _build_pipeline(run_id)
pipeline = _build_pipeline(run_id, unbound_session_factory)
pipeline._save_workflow_app_log = MagicMock()
event = QueueWorkflowStartedEvent(reason=WorkflowStartReason.RESUMPTION)

View File

@ -1,5 +1,6 @@
from __future__ import annotations
import json
import logging
from types import SimpleNamespace
@ -53,12 +54,35 @@ from graphon.enums import BuiltinNodeTypes, WorkflowExecutionStatus
from graphon.model_runtime.entities.llm_entities import LLMUsage
from graphon.runtime import GraphRuntimeState, VariablePool
from libs.datetime_utils import naive_utc_now
from models.enums import CreatorUserRole
from models.enums import CreatorUserRole, EndUserType
from models.model import AppMode, EndUser
from models.workflow import WorkflowAppLog
from models.workflow import Workflow, WorkflowAppLog, WorkflowType
from tests.workflow_test_utils import build_test_variable_pool
def _workflow() -> Workflow:
return Workflow(
id="workflow-id",
tenant_id="tenant",
app_id="app",
type=WorkflowType.WORKFLOW,
version=Workflow.VERSION_DRAFT,
graph="{}",
features=json.dumps({}),
created_by="user",
)
def _end_user(*, end_user_id: str = "user", session_id: str = "session") -> EndUser:
return EndUser(
id=end_user_id,
tenant_id="tenant",
app_id="app",
type=EndUserType.BROWSER,
session_id=session_id,
)
def _make_pipeline():
app_config = WorkflowUIBasedAppConfig(
tenant_id="tenant",
@ -81,8 +105,8 @@ def _make_pipeline():
extras={},
call_depth=0,
)
workflow = SimpleNamespace(id="workflow-id", tenant_id="tenant", features_dict={})
user = SimpleNamespace(id="user", session_id="session")
workflow = _workflow()
user = _end_user()
pipeline = WorkflowAppGenerateTaskPipeline(
application_generate_entity=application_generate_entity,
@ -505,10 +529,9 @@ class TestWorkflowGenerateTaskPipeline:
extras={},
call_depth=0,
)
workflow = SimpleNamespace(id="workflow-id", tenant_id="tenant", features_dict={})
workflow = _workflow()
queue_manager = SimpleNamespace(invoke_from=InvokeFrom.WEB_APP, graph_runtime_state=None)
end_user = EndUser(tenant_id="tenant", type="session", name="user", session_id="session-id")
end_user.id = "end-user-id"
end_user = _end_user(end_user_id="end-user-id", session_id="session-id")
pipeline = WorkflowAppGenerateTaskPipeline(
application_generate_entity=application_generate_entity,

View File

@ -10,11 +10,28 @@ from core.app.entities.app_invoke_entities import InvokeFrom
from core.app.features.annotation_reply.annotation_reply import AnnotationReplyFeature
from models.dataset import DatasetCollectionBinding
from models.enums import CollectionBindingType, ConversationFromSource
from models.model import AppAnnotationHitHistory, AppAnnotationSetting, MessageAnnotation
from models.model import App, AppAnnotationHitHistory, AppAnnotationSetting, AppMode, Message, MessageAnnotation
TABLES = (AppAnnotationSetting, DatasetCollectionBinding, MessageAnnotation, AppAnnotationHitHistory)
def _app() -> App:
return App(
id="app-1",
tenant_id="tenant-1",
name="Test App",
description="",
mode=AppMode.CHAT,
enable_site=True,
enable_api=True,
max_active_requests=0,
)
def _message() -> Message:
return Message(id="msg-1")
def _persist_binding(session: Session) -> DatasetCollectionBinding:
binding = DatasetCollectionBinding(
provider_name="prov",
@ -65,8 +82,8 @@ class TestAnnotationReplyFeature:
_persist_setting(sqlite_session, app_id="other-app", collection_binding_id=binding.id)
result = AnnotationReplyFeature().query(
app_record=SimpleNamespace(id="app-1", tenant_id="tenant-1"),
message=SimpleNamespace(id="msg-1"),
app_record=_app(),
message=_message(),
query="hi",
user_id="user-1",
invoke_from=InvokeFrom.SERVICE_API,
@ -79,8 +96,8 @@ class TestAnnotationReplyFeature:
_persist_setting(sqlite_session, collection_binding_id="missing-binding")
result = AnnotationReplyFeature().query(
app_record=SimpleNamespace(id="app-1", tenant_id="tenant-1"),
message=SimpleNamespace(id="msg-1"),
app_record=_app(),
message=_message(),
query="hi",
user_id="user-1",
invoke_from=InvokeFrom.SERVICE_API,
@ -101,8 +118,8 @@ class TestAnnotationReplyFeature:
"core.app.features.annotation_reply.annotation_reply.Vector", return_value=vector_instance
) as vector_cls:
result = AnnotationReplyFeature().query(
app_record=SimpleNamespace(id="app-1", tenant_id="tenant-1"),
message=SimpleNamespace(id="msg-1"),
app_record=_app(),
message=_message(),
query="hi",
user_id="user-1",
invoke_from=InvokeFrom.SERVICE_API,
@ -135,8 +152,8 @@ class TestAnnotationReplyFeature:
with patch("core.app.features.annotation_reply.annotation_reply.Vector", return_value=vector_instance):
result = AnnotationReplyFeature().query(
app_record=SimpleNamespace(id="app-1", tenant_id="tenant-1"),
message=SimpleNamespace(id="msg-1"),
app_record=_app(),
message=_message(),
query="hi",
user_id="user-1",
invoke_from=InvokeFrom.EXPLORE,
@ -162,8 +179,8 @@ class TestAnnotationReplyFeature:
caplog.at_level(logging.WARNING),
):
result = AnnotationReplyFeature().query(
app_record=SimpleNamespace(id="app-1", tenant_id="tenant-1"),
message=SimpleNamespace(id="msg-1"),
app_record=_app(),
message=_message(),
query="hi",
user_id="user-1",
invoke_from=InvokeFrom.SERVICE_API,

View File

@ -1,4 +1,5 @@
from collections.abc import Iterator
from datetime import UTC, datetime
from types import SimpleNamespace
from unittest.mock import Mock, patch
@ -31,7 +32,8 @@ 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
from graphon.model_runtime.entities.message_entities import TextPromptMessageContent
from models.model import AppMode
from models.enums import ConversationFromSource
from models.model import AppMode, Conversation, Message
@pytest.fixture
@ -92,21 +94,25 @@ class TestEasyUIBasedGenerateTaskPipelineProcessStreamResponse:
return manager
@pytest.fixture
def mock_conversation(self):
"""Create a mock conversation."""
conversation = Mock()
conversation.id = "test-conversation-id"
conversation.mode = "chat"
return conversation
def conversation(self):
"""Create a transient mapped conversation."""
return Conversation(
id="test-conversation-id",
app_id="test-app-id",
mode=AppMode.CHAT,
name="Test Conversation",
status="normal",
from_source=ConversationFromSource.API,
inputs={},
)
@pytest.fixture
def mock_message(self):
"""Create a mock message."""
message = Mock()
message.id = "test-message-id"
message.created_at = Mock()
message.created_at.timestamp.return_value = 1234567890
return message
def message(self):
"""Create a transient mapped message."""
return Message(
id="test-message-id",
created_at=datetime.fromtimestamp(1234567890, tz=UTC),
)
@pytest.fixture
def mock_task_state(self):
@ -129,8 +135,8 @@ class TestEasyUIBasedGenerateTaskPipelineProcessStreamResponse:
self,
mock_application_generate_entity,
mock_queue_manager,
mock_conversation,
mock_message,
conversation,
message,
mock_message_cycle_manager,
mock_task_state,
):
@ -141,8 +147,8 @@ class TestEasyUIBasedGenerateTaskPipelineProcessStreamResponse:
pipeline = EasyUIBasedGenerateTaskPipeline(
application_generate_entity=mock_application_generate_entity,
queue_manager=mock_queue_manager,
conversation=mock_conversation,
message=mock_message,
conversation=conversation,
message=message,
stream=True,
)
pipeline._message_cycle_manager = mock_message_cycle_manager

View File

@ -20,7 +20,7 @@ from graphon.file import FileTransferMethod, FileType
from models import model as model_module
from models.base import TypeBase
from models.enums import ConversationFromSource, CreatorUserRole, MessageFileBelongsTo
from models.model import App, AppMode, Conversation, MessageFile
from models.model import App, AppMode, Conversation, MessageAnnotation, MessageFile
@dataclass(frozen=True)
@ -426,10 +426,13 @@ class TestMessageCycleManagerOptimization:
"""
message_cycle_manager._task_state = SimpleNamespace(metadata=TaskStateMetadata())
annotation = SimpleNamespace(
id="ann-1",
annotation = MessageAnnotation(
app_id="app-id",
question="question",
content="answer",
account_id="acct-1",
)
annotation.id = "ann-1"
session = unbound_session
with (