diff --git a/api/controllers/common/agent_app_parameters.py b/api/controllers/common/agent_app_parameters.py deleted file mode 100644 index 5bc41379444..00000000000 --- a/api/controllers/common/agent_app_parameters.py +++ /dev/null @@ -1,60 +0,0 @@ -from typing import Any - -from sqlalchemy import select -from sqlalchemy.orm import Session - -from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot -from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features -from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form -from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError -from models.agent import Agent, AgentConfigSnapshot, AgentStatus -from models.agent_config_entities import AgentSoulConfig -from models.model import App, load_annotation_reply_config - - -def get_published_agent_app_feature_dict_and_user_input_form( - app_model: App, - *, - session: Session, -) -> tuple[dict[str, Any], list[dict[str, Any]]]: - """Return public Agent App parameters backed by the published Agent Soul.""" - app_model_config = app_model.app_model_config_with_session(session=session) - - agent_id = app_model.bound_agent_id - if not agent_id: - raise AgentAppGeneratorError("Agent App has no bound Agent") - - agent = session.scalar( - select(Agent) - .where( - Agent.tenant_id == app_model.tenant_id, - Agent.id == agent_id, - Agent.status == AgentStatus.ACTIVE, - ) - .limit(1) - ) - if agent is None: - raise AgentAppGeneratorError("Agent App has no bound Agent") - if not agent_has_workflow_callable_active_snapshot(session=session, agent=agent): - raise AgentAppNotPublishedError("Agent has not been published") - - snapshot = session.scalar( - select(AgentConfigSnapshot) - .where( - AgentConfigSnapshot.tenant_id == app_model.tenant_id, - AgentConfigSnapshot.agent_id == agent.id, - AgentConfigSnapshot.id == agent.active_config_snapshot_id, - ) - .limit(1) - ) - if snapshot is None: - raise AgentAppGeneratorError("Agent published version not found") - - agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict) - annotation_reply = load_annotation_reply_config(session, app_model.id) if app_model_config else None - features_dict = merge_agent_app_features( - agent_soul=agent_soul, - app_model_config=app_model_config, - annotation_reply=annotation_reply, - ) - return features_dict, agent_app_variables_to_user_input_form(agent_soul.app_variables) diff --git a/api/controllers/console/explore/trial.py b/api/controllers/console/explore/trial.py index 620f3d3f85e..bde115b6f25 100644 --- a/api/controllers/console/explore/trial.py +++ b/api/controllers/console/explore/trial.py @@ -1,5 +1,4 @@ import logging -from collections.abc import Mapping from dataclasses import dataclass from datetime import datetime from typing import Any, Literal @@ -51,7 +50,6 @@ from controllers.console.files import FILE_UPLOAD_PARAMS, upload_file_from_reque from controllers.console.remote_files import RemoteFileUploadPayload, upload_remote_file_from_request from controllers.console.wraps import cloud_edition_billing_resource_check, model_validate, with_current_user from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError -from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict from core.app.apps.base_app_queue_manager import AppQueueManager from core.app.entities.app_invoke_entities import InvokeFrom from core.errors.error import ( @@ -61,6 +59,7 @@ from core.errors.error import ( ) from core.helper import encrypter from core.workflow.llm_environment_variable import LLMEnvironmentVariable, dump_environment_variable +from extensions.ext_application_services import application_services from extensions.ext_database import db from extensions.ext_redis import redis_client from fields.base import ResponseModel @@ -74,9 +73,10 @@ from libs import helper from libs.helper import dump_response, to_timestamp, uuid_value from models import Account, App from models.account import TenantStatus -from models.model import AppMode, Site, load_annotation_reply_config +from models.model import AppMode, Site from models.workflow import Workflow from services.account_service import TenantService +from services.app_definition_query_service import AppDefinitionUnavailableError from services.app_generate_service import AppGenerateService from services.app_ref_service import AppRefService from services.app_service import AppResponseView, AppService @@ -855,26 +855,12 @@ class TrialAppParameterApi(Resource): if app_model is None: raise AppUnavailableError() - features_dict: Mapping[str, Any] - if app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: - workflow = app_model.workflow_with_session(session=session) - if workflow is None: - raise AppUnavailableError() + try: + parameters = application_services().app_definitions.get_parameters(app_model.id) + except AppDefinitionUnavailableError: + raise AppUnavailableError() from None - features_dict = workflow.features_dict - user_input_form = workflow.user_input_form(to_old_structure=True) - else: - app_model_config = app_model.app_model_config_with_session(session=session) - if app_model_config is None: - raise AppUnavailableError() - - annotation_reply = load_annotation_reply_config(session, app_model_config.app_id) - features_dict = app_model_config.to_dict(annotation_reply=annotation_reply) - - user_input_form = features_dict.get("user_input_form", []) - - parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form) - return ParametersResponse.model_validate(parameters).model_dump(mode="json") + return dump_response(ParametersResponse, parameters) class AppApi(Resource): diff --git a/api/controllers/service_api/app/app.py b/api/controllers/service_api/app/app.py index a685004b561..ff0db136fcc 100644 --- a/api/controllers/service_api/app/app.py +++ b/api/controllers/service_api/app/app.py @@ -1,23 +1,18 @@ -from typing import Any, cast +from typing import Any from flask_restx import Resource from pydantic import Field -from sqlalchemy.orm import Session -from controllers.common.agent_app_parameters import get_published_agent_app_feature_dict_and_user_input_form from controllers.common.fields import Parameters from controllers.common.schema import register_response_schema_models from controllers.service_api import service_api_ns from controllers.service_api.app.error import AgentNotPublishedError, AppUnavailableError from controllers.service_api.wraps import validate_app_token -from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict -from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError from extensions.ext_application_services import application_services -from extensions.ext_database import db from fields.base import ResponseModel from libs.helper import dump_response -from models.model import App, AppMode, load_annotation_reply_config -from services.app_definition_query_service import AppDefinitionUnavailableError +from models.model import App +from services.app_definition_query_service import AppDefinitionNotPublishedError, AppDefinitionUnavailableError class AppInfoResponse(ResponseModel): @@ -35,19 +30,6 @@ class AppMetaResponse(ResponseModel): register_response_schema_models(service_api_ns, Parameters, AppMetaResponse, AppInfoResponse) -def _get_agent_app_feature_dict_and_user_input_form( - app_model: App, - *, - session: Session, -) -> tuple[dict[str, Any], list[dict[str, Any]]]: - try: - return get_published_agent_app_feature_dict_and_user_input_form(app_model, session=session) - except AgentAppNotPublishedError: - raise AgentNotPublishedError() - except AgentAppGeneratorError: - raise AppUnavailableError() - - @service_api_ns.route("/parameters") class AppParameterApi(Resource): """Resource for app variables.""" @@ -80,36 +62,14 @@ class AppParameterApi(Resource): Returns the input form parameters and configuration for the application. """ - session = db.session() - features_dict: dict[str, Any] - user_input_form: list[dict[str, Any]] - if app_model.mode == AppMode.AGENT: - features_dict, user_input_form = _get_agent_app_feature_dict_and_user_input_form( - app_model, - session=session, - ) - elif app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: - workflow = app_model.workflow_with_session(session=session) - if workflow is None: - raise AppUnavailableError() + try: + parameters = application_services().app_definitions.get_public_parameters(app_model.id) + except AppDefinitionNotPublishedError: + raise AgentNotPublishedError() from None + except AppDefinitionUnavailableError: + raise AppUnavailableError() from None - features_dict = workflow.features_dict - user_input_form = workflow.user_input_form(to_old_structure=True) - else: - app_model_config = app_model.app_model_config_with_session(session=session) - if app_model_config is None: - raise AppUnavailableError() - - annotation_reply = load_annotation_reply_config(session, app_model.id) - features_dict = cast( - dict[str, Any], - app_model_config.to_dict(annotation_reply=annotation_reply), - ) - - user_input_form = features_dict.get("user_input_form", []) - - parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form) - return Parameters.model_validate(parameters).model_dump(mode="json") + return dump_response(Parameters, parameters) @service_api_ns.route("/meta") diff --git a/api/controllers/web/app.py b/api/controllers/web/app.py index 9a39652ce58..a6d8dc47109 100644 --- a/api/controllers/web/app.py +++ b/api/controllers/web/app.py @@ -1,5 +1,5 @@ import logging -from typing import Any, cast +from typing import Any from flask import request from flask_restx import Resource @@ -8,17 +8,15 @@ from werkzeug.exceptions import Unauthorized from constants import HEADER_NAME_APP_CODE from controllers.common import fields -from controllers.common.agent_app_parameters import get_published_agent_app_feature_dict_and_user_input_form +from controllers.common.fields import Parameters from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models -from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict -from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError from extensions.ext_application_services import application_services from extensions.ext_database import db from libs.helper import dump_response from libs.passport import PassportService from libs.token import extract_webapp_passport -from models.model import App, AppMode, EndUser, load_annotation_reply_config -from services.app_definition_query_service import AppDefinitionUnavailableError +from models.model import App, EndUser +from services.app_definition_query_service import AppDefinitionNotPublishedError, AppDefinitionUnavailableError from services.app_service import AppService from services.enterprise.enterprise_service import EnterpriseService from services.feature_service import FeatureService @@ -54,7 +52,7 @@ class AppMetaResponse(BaseModel): register_schema_models(web_ns, AppAccessModeQuery, AppPermissionQuery) register_response_schema_models( web_ns, - fields.Parameters, + Parameters, AppMetaResponse, fields.AccessModeResponse, fields.BooleanResultResponse, @@ -77,44 +75,17 @@ class AppParameterApi(WebApiResource): 500: "Internal Server Error", } ) - @web_ns.response(200, "Success", web_ns.models[fields.Parameters.__name__]) + @web_ns.response(200, "Success", web_ns.models[Parameters.__name__]) def get(self, app_model: App, end_user: EndUser): """Retrieve app parameters.""" - session = db.session() - features_dict: dict[str, Any] - user_input_form: list[dict[str, Any]] - if app_model.mode == AppMode.AGENT: - try: - features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form( - app_model, - session=session, - ) - except AgentAppNotPublishedError: - raise AgentNotPublishedError() - except AgentAppGeneratorError: - raise AppUnavailableError() - elif app_model.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: - workflow = app_model.workflow_with_session(session=session) - if workflow is None: - raise AppUnavailableError() + try: + parameters = application_services().app_definitions.get_public_parameters(app_model.id) + except AppDefinitionNotPublishedError: + raise AgentNotPublishedError() from None + except AppDefinitionUnavailableError: + raise AppUnavailableError() from None - features_dict = workflow.features_dict - user_input_form = workflow.user_input_form(to_old_structure=True) - else: - app_model_config = app_model.app_model_config_with_session(session=session) - if app_model_config is None: - raise AppUnavailableError() - - annotation_reply = load_annotation_reply_config(session, app_model.id) - features_dict = cast( - dict[str, Any], - app_model_config.to_dict(annotation_reply=annotation_reply), - ) - - user_input_form = features_dict.get("user_input_form", []) - - parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form) - return fields.Parameters.model_validate(parameters).model_dump(mode="json") + return dump_response(Parameters, parameters) @web_ns.route("/meta") diff --git a/api/repositories/app_definition_query_repository.py b/api/repositories/app_definition_query_repository.py index 511b040a5a6..9fa4e6014aa 100644 --- a/api/repositories/app_definition_query_repository.py +++ b/api/repositories/app_definition_query_repository.py @@ -2,25 +2,72 @@ from typing import Any, cast, override +from sqlalchemy import select from sqlalchemy.orm import Session, sessionmaker +from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot +from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features +from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form +from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError +from models.agent import AgentConfigSnapshot +from models.agent_config_entities import AgentSoulConfig from models.model import App, AppMode, AppModelConfig, load_annotation_reply_config from models.tools import ApiToolProvider from models.workflow import Workflow from services.app_definition_query_service import AppDefinitionQuery, AppParameterConfig, AppToolIconSource +def _get_public_agent_parameter_config(app: App, *, session: Session) -> AppParameterConfig: + app_model_config = app.app_model_config_with_session(session=session) + agent = app.agent_app_binding_with_session(session=session) + if agent is None: + raise AgentAppGeneratorError("Agent App has no bound Agent") + if not agent_has_workflow_callable_active_snapshot(session=session, agent=agent): + raise AgentAppNotPublishedError("Agent has not been published") + + snapshot = session.scalar( + select(AgentConfigSnapshot) + .where( + AgentConfigSnapshot.tenant_id == app.tenant_id, + AgentConfigSnapshot.agent_id == agent.id, + AgentConfigSnapshot.id == agent.active_config_snapshot_id, + ) + .limit(1) + ) + if snapshot is None: + raise AgentAppGeneratorError("Agent published version not found") + + agent_soul = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict) + annotation_reply = load_annotation_reply_config(session, app.id) if app_model_config else None + return AppParameterConfig( + features_dict=merge_agent_app_features( + agent_soul=agent_soul, + app_model_config=app_model_config, + annotation_reply=annotation_reply, + ), + user_input_form=agent_app_variables_to_user_input_form(agent_soul.app_variables), + ) + + class AppDefinitionQueryRepository(AppDefinitionQuery): def __init__(self, *, session_factory: sessionmaker[Session]) -> None: self._session_factory = session_factory @override - def get_published_parameter_config(self, app_id: str) -> AppParameterConfig | None: + def get_published_parameter_config( + self, + app_id: str, + *, + public_runtime: bool = False, + ) -> AppParameterConfig | None: with self._session_factory() as session: app = session.get(App, app_id) if app is None: return None + if public_runtime and app.mode == AppMode.AGENT: + return _get_public_agent_parameter_config(app, session=session) + if app.mode in {AppMode.ADVANCED_CHAT, AppMode.WORKFLOW}: workflow = app.workflow_with_session(session=session) if workflow is None: diff --git a/api/services/app_definition_query_service.py b/api/services/app_definition_query_service.py index 74f41b5726a..14a5cb8cf9b 100644 --- a/api/services/app_definition_query_service.py +++ b/api/services/app_definition_query_service.py @@ -5,6 +5,7 @@ from collections.abc import Mapping, Sequence from typing import Any, NamedTuple, Protocol from core.app.app_config.common.parameters_mapping import AppParametersDict, get_parameters_from_feature_dict +from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError class AppParameterConfig(NamedTuple): @@ -20,7 +21,12 @@ class AppToolIconSource(NamedTuple): class AppDefinitionQuery(Protocol): - def get_published_parameter_config(self, app_id: str) -> AppParameterConfig | None: ... + def get_published_parameter_config( + self, + app_id: str, + *, + public_runtime: bool = False, + ) -> AppParameterConfig | None: ... def get_tool_icon_sources(self, app_id: str) -> Sequence[AppToolIconSource] | None: ... @@ -29,6 +35,10 @@ class AppDefinitionUnavailableError(ValueError): """Raised when an app definition is unavailable.""" +class AppDefinitionNotPublishedError(AppDefinitionUnavailableError): + """Raised when a public Agent App has not been published.""" + + _API_TOOL_FALLBACK_ICON = {"background": "#252525", "content": "\ud83d\ude01"} @@ -44,6 +54,21 @@ class AppDefinitionQueryService: def get_parameters(self, app_id: str) -> AppParametersDict: config = self._definitions.get_published_parameter_config(app_id) + return self._map_parameters(config) + + def get_public_parameters(self, app_id: str) -> AppParametersDict: + """Read public parameters, using the published Soul for Agent Apps.""" + try: + config = self._definitions.get_published_parameter_config(app_id, public_runtime=True) + except AgentAppNotPublishedError: + raise AppDefinitionNotPublishedError from None + except AgentAppGeneratorError: + raise AppDefinitionUnavailableError from None + + return self._map_parameters(config) + + @staticmethod + def _map_parameters(config: AppParameterConfig | None) -> AppParametersDict: if config is None: raise AppDefinitionUnavailableError diff --git a/api/tests/unit_tests/.ruff.toml b/api/tests/unit_tests/.ruff.toml index 2e9b34cf208..38033743c9a 100644 --- a/api/tests/unit_tests/.ruff.toml +++ b/api/tests/unit_tests/.ruff.toml @@ -10,7 +10,7 @@ extend-select = ["ANN401", "ARG"] "commands/test_archive_workflow_runs.py" = ["ARG005"] "commands/test_data_migration_wizard.py" = ["ARG005"] "commands/test_legacy_model_type_migration.py" = ["ARG001", "ARG002", "ARG005"] -"controllers/common/test_agent_app_parameters.py" = ["ARG005", "TID251"] +"repositories/test_app_definition_query_repository_agent.py" = ["ARG005", "TID251"] "controllers/common/test_app_access.py" = ["ARG005"] "controllers/console/agent/test_agent_controllers.py" = ["ARG001", "ARG002", "ARG003", "ARG005", "TID251"] "controllers/console/app/test_agent_app_sandbox.py" = ["ARG002", "ARG005"] diff --git a/api/tests/unit_tests/controllers/console/explore/test_trial.py b/api/tests/unit_tests/controllers/console/explore/test_trial.py index d8838a76251..2024ec9473d 100644 --- a/api/tests/unit_tests/controllers/console/explore/test_trial.py +++ b/api/tests/unit_tests/controllers/console/explore/test_trial.py @@ -30,6 +30,7 @@ from controllers.console.explore.error import ( ) from controllers.console.explore.trial import ChatRequest, CompletionRequest, TextToSpeechRequest, WorkflowRunRequest from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError +from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict from core.errors.error import ( ModelCurrentlyNotSupportError, ProviderTokenNotInitError, @@ -109,23 +110,6 @@ def trial_app_workflow() -> MagicMock: return app -@pytest.fixture -def valid_parameters() -> dict[str, object]: - return { - "user_input_form": [], - "system_parameters": {}, - "suggested_questions": {}, - "suggested_questions_after_answer": {}, - "speech_to_text": {}, - "text_to_speech": {}, - "retriever_resource": {}, - "annotation_reply": {}, - "more_like_this": {}, - "sensitive_word_avoidance": {}, - "file_upload": {}, - } - - def test_trial_workflow_uses_trial_scoped_simple_account_model() -> None: assert module.simple_account_model.name == "TrialSimpleAccount" assert module.simple_account_model.__schema__["properties"].keys() >= {"id", "name", "email"} @@ -888,63 +872,33 @@ class TestTrialAppParameterApi: with pytest.raises(AppUnavailableError): method(api, unbound_session, None) - def test_success_non_workflow(self, valid_parameters: dict[str, object], unbound_session: Session) -> None: + def test_success(self, unbound_session: Session) -> None: api = module.TrialAppParameterApi() method = unwrap(api.get) + parameters = get_parameters_from_feature_dict(features_dict={}, user_input_form=[]) + expected = module.ParametersResponse.model_validate(parameters).model_dump(mode="json") + app_definitions = MagicMock() + app_definitions.get_parameters.return_value = parameters + services = SimpleNamespace(app_definitions=app_definitions) - app_model_config = MagicMock(app_id="app-1") - app_model_config.to_dict.return_value = {"user_input_form": []} - app_model = SimpleNamespace( - mode=AppMode.CHAT, - app_model_config_with_session=MagicMock(return_value=app_model_config), - ) - annotation_reply = {"enabled": False} + with patch.object(module, "application_services", return_value=services): + result = method(api, unbound_session, SimpleNamespace(id="app-1")) - with ( - patch.object( - module, "load_annotation_reply_config", return_value=annotation_reply - ) as load_annotation_reply, - patch.object( - module, - "get_parameters_from_feature_dict", - return_value=valid_parameters, - ), - patch.object( - module.ParametersResponse, - "model_validate", - return_value=MagicMock(model_dump=lambda mode=None: {"ok": True}), - ), - ): - result = method(api, unbound_session, app_model) + assert result == expected + app_definitions.get_parameters.assert_called_once_with("app-1") - assert result == {"ok": True} - app_model.app_model_config_with_session.assert_called_once_with(session=unbound_session) - load_annotation_reply.assert_called_once_with(unbound_session, "app-1") - app_model_config.to_dict.assert_called_once_with(annotation_reply=annotation_reply) - - def test_success_workflow(self, valid_parameters: dict[str, object], unbound_session: Session) -> None: + def test_unavailable_parameters(self, unbound_session: Session) -> None: api = module.TrialAppParameterApi() method = unwrap(api.get) + app_definitions = MagicMock() + app_definitions.get_parameters.side_effect = module.AppDefinitionUnavailableError + services = SimpleNamespace(app_definitions=app_definitions) - workflow = MagicMock(features_dict={}) - workflow.user_input_form.return_value = [] - app_model = SimpleNamespace( - mode=AppMode.WORKFLOW, - workflow_with_session=MagicMock(return_value=workflow), - ) with ( - patch.object(module, "get_parameters_from_feature_dict", return_value=valid_parameters), - patch.object( - module.ParametersResponse, - "model_validate", - return_value=MagicMock(model_dump=lambda mode=None: {"ok": True}), - ), + patch.object(module, "application_services", return_value=services), + pytest.raises(AppUnavailableError), ): - result = method(api, unbound_session, app_model) - - assert result == {"ok": True} - app_model.workflow_with_session.assert_called_once_with(session=unbound_session) - workflow.user_input_form.assert_called_once_with(to_old_structure=True) + method(api, unbound_session, SimpleNamespace(id="app-1")) class TestTrialChatAudioApi: diff --git a/api/tests/unit_tests/controllers/service_api/app/test_app.py b/api/tests/unit_tests/controllers/service_api/app/test_app.py index 1d89a7a9ea9..fbf4e88e83a 100644 --- a/api/tests/unit_tests/controllers/service_api/app/test_app.py +++ b/api/tests/unit_tests/controllers/service_api/app/test_app.py @@ -1,11 +1,10 @@ """SQLite-backed tests for Service API application controllers. The authentication decorator resolves the app, tenant, and tenant owner before -the controller runs. Controller/model code then reads configuration, workflow, -tags, and author information through two additional references to the database -extension. Tests bind all of those references to one explicit scoped SQLite -session and persist visibility and cross-tenant decoys instead of fabricating ORM -lookup results. +the controller runs. Controller/model code then reads tags and author information +through model database properties. Tests bind those references to one explicit +scoped SQLite session and persist visibility and cross-tenant decoys instead of +fabricating ORM lookup results. """ import json @@ -24,7 +23,7 @@ from werkzeug.exceptions import Forbidden, Unauthorized from controllers.service_api.app import app as app_controller from controllers.service_api.app.app import AppInfoApi, AppMetaApi, AppParameterApi from controllers.service_api.app.error import AgentNotPublishedError, AppUnavailableError -from core.app.apps.agent_app.errors import AgentAppNotPublishedError +from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole, TenantStatus from models.base import TypeBase from models.enums import EndUserType @@ -41,7 +40,7 @@ from models.model import ( TagType, ) from models.workflow import Workflow, WorkflowType -from services.app_definition_query_service import AppDefinitionUnavailableError +from services.app_definition_query_service import AppDefinitionNotPublishedError, AppDefinitionUnavailableError @dataclass(frozen=True) @@ -139,7 +138,6 @@ def app_db(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[A registry = scoped_session(maker) binding = _DatabaseBinding(engine=sqlite_engine, session=registry) monkeypatch.setattr("controllers.service_api.wraps.db", binding) - monkeypatch.setattr(app_controller, "db", binding) monkeypatch.setattr("models.model.db", binding) monkeypatch.setattr("models.account.db", binding) @@ -270,79 +268,51 @@ def authenticated_controller(app_db: AppDatabase, monkeypatch: pytest.MonkeyPatc return app_db -@pytest.mark.usefixtures("authenticated_controller") -def test_get_parameters_for_persisted_chat_config(flask_app: Flask) -> None: - with flask_app.test_request_context("/parameters", headers={"Authorization": "Bearer token"}): - response = AppParameterApi().get() - - assert response["opening_statement"] == "Hello" - assert response["suggested_questions"] == ["Question?"] - assert response["user_input_form"] == [{"text-input": {"label": "Name", "variable": "name", "required": True}}] - - -def test_get_parameters_for_persisted_workflow(flask_app: Flask, authenticated_controller: AppDatabase) -> None: - authenticated_controller.update_app(mode=AppMode.WORKFLOW, app_model_config_id=None) - - with flask_app.test_request_context("/parameters", headers={"Authorization": "Bearer token"}): - response = AppParameterApi().get() - - assert response["user_input_form"] == [] - assert response["suggested_questions"] == [] - - -def test_get_parameters_for_agent_uses_persisted_app( +def test_get_parameters_queries_authenticated_app( flask_app: Flask, authenticated_controller: AppDatabase, monkeypatch: pytest.MonkeyPatch ) -> None: - authenticated_controller.update_app(mode=AppMode.AGENT, app_model_config_id=None, workflow_id=None) - user_input_form = [{"text-input": {"label": "Topic", "variable": "topic", "required": True}}] - agent_parameters = Mock( - return_value=( - {"opening_statement": "Hi from Agent"}, - user_input_form, - ) + app_definitions = Mock() + app_definitions.get_public_parameters.return_value = get_parameters_from_feature_dict( + features_dict={"opening_statement": "Hello"}, + user_input_form=[], ) - monkeypatch.setattr(app_controller, "_get_agent_app_feature_dict_and_user_input_form", agent_parameters) - - with flask_app.test_request_context("/parameters", headers={"Authorization": "Bearer token"}): - response = AppParameterApi().get() - - assert response["opening_statement"] == "Hi from Agent" - assert response["user_input_form"] == user_input_form - agent_parameters.assert_called_once() - (app_model,) = agent_parameters.call_args.args - assert app_model.id == authenticated_controller.app_id - assert agent_parameters.call_args.kwargs["session"] is authenticated_controller.registry() - - -def test_unpublished_agent_raises_friendly_error( - flask_app: Flask, authenticated_controller: AppDatabase, monkeypatch: pytest.MonkeyPatch -) -> None: - authenticated_controller.update_app(mode=AppMode.AGENT) monkeypatch.setattr( app_controller, - "get_published_agent_app_feature_dict_and_user_input_form", - Mock(side_effect=AgentAppNotPublishedError("not published")), + "application_services", + Mock(return_value=SimpleNamespace(app_definitions=app_definitions)), ) with flask_app.test_request_context("/parameters", headers={"Authorization": "Bearer token"}): - with pytest.raises(AgentNotPublishedError): - AppParameterApi().get() + response = AppParameterApi().get() + + app_definitions.get_public_parameters.assert_called_once_with(authenticated_controller.app_id) + assert response["opening_statement"] == "Hello" @pytest.mark.parametrize( - ("mode", "field"), - [(AppMode.CHAT, "app_model_config_id"), (AppMode.WORKFLOW, "workflow_id")], + ("service_error", "http_error"), + [ + pytest.param(AppDefinitionNotPublishedError(), AgentNotPublishedError, id="not-published"), + pytest.param(AppDefinitionUnavailableError(), AppUnavailableError, id="unavailable"), + ], ) -def test_parameters_reject_missing_persisted_configuration( +@pytest.mark.usefixtures("authenticated_controller") +def test_get_parameters_maps_query_errors( flask_app: Flask, - authenticated_controller: AppDatabase, - mode: AppMode, - field: str, + monkeypatch: pytest.MonkeyPatch, + service_error: Exception, + http_error: type[Exception], ) -> None: - authenticated_controller.update_app(mode=mode, **{field: None}) + app_definitions = Mock() + app_definitions.get_public_parameters.side_effect = service_error + monkeypatch.setattr( + app_controller, + "application_services", + Mock(return_value=SimpleNamespace(app_definitions=app_definitions)), + ) with flask_app.test_request_context("/parameters", headers={"Authorization": "Bearer token"}): - with pytest.raises(AppUnavailableError): + with pytest.raises(http_error): AppParameterApi().get() diff --git a/api/tests/unit_tests/controllers/web/test_app.py b/api/tests/unit_tests/controllers/web/test_app.py index ef216e8d34d..da308ec9051 100644 --- a/api/tests/unit_tests/controllers/web/test_app.py +++ b/api/tests/unit_tests/controllers/web/test_app.py @@ -10,106 +10,52 @@ from flask import Flask from controllers.web.app import AppAccessMode, AppMeta, AppParameterApi, AppWebAuthPermission from controllers.web.error import AgentNotPublishedError, AppUnavailableError -from core.app.apps.agent_app.errors import AgentAppNotPublishedError -from services.app_definition_query_service import AppDefinitionUnavailableError +from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict +from services.app_definition_query_service import AppDefinitionNotPublishedError, AppDefinitionUnavailableError # --------------------------------------------------------------------------- # AppParameterApi # --------------------------------------------------------------------------- class TestAppParameterApi: - def test_advanced_chat_mode_uses_workflow(self, app: Flask) -> None: - features_dict = {"opening_statement": "Hello"} - workflow = SimpleNamespace( - features_dict=features_dict, - user_input_form=lambda to_old_structure=False: [], - ) - app_model = SimpleNamespace( - mode="advanced-chat", - workflow_with_session=lambda *, session: workflow, + @patch("controllers.web.app.application_services") + def test_get_returns_public_parameters(self, application_services: MagicMock, app: Flask) -> None: + app_definitions = MagicMock() + app_definitions.get_public_parameters.return_value = get_parameters_from_feature_dict( + features_dict={"opening_statement": "Hello"}, + user_input_form=[], ) + application_services.return_value = SimpleNamespace(app_definitions=app_definitions) + app_model = SimpleNamespace(id="app-1") - with ( - app.test_request_context("/parameters"), - patch("controllers.web.app.get_parameters_from_feature_dict", return_value={}) as mock_params, - patch("controllers.web.app.fields.Parameters") as mock_fields, - ): - mock_fields.model_validate.return_value.model_dump.return_value = {"result": "ok"} + with app.test_request_context("/parameters"): result = AppParameterApi().get(app_model, SimpleNamespace()) - mock_params.assert_called_once_with(features_dict=features_dict, user_input_form=[]) - assert result == {"result": "ok"} + assert result["opening_statement"] == "Hello" + app_definitions.get_public_parameters.assert_called_once_with("app-1") - def test_workflow_mode_uses_workflow(self, app: Flask) -> None: - features_dict = {} - workflow = SimpleNamespace( - features_dict=features_dict, - user_input_form=lambda to_old_structure=False: [{"var": "x"}], - ) - app_model = SimpleNamespace( - mode="workflow", - workflow_with_session=lambda *, session: workflow, - ) + @pytest.mark.parametrize( + ("service_error", "http_error"), + [ + pytest.param(AppDefinitionNotPublishedError(), AgentNotPublishedError, id="not-published"), + pytest.param(AppDefinitionUnavailableError(), AppUnavailableError, id="unavailable"), + ], + ) + @patch("controllers.web.app.application_services") + def test_get_maps_query_errors( + self, + application_services: MagicMock, + service_error: Exception, + http_error: type[Exception], + app: Flask, + ) -> None: + app_definitions = MagicMock() + app_definitions.get_public_parameters.side_effect = service_error + application_services.return_value = SimpleNamespace(app_definitions=app_definitions) - with ( - app.test_request_context("/parameters"), - patch("controllers.web.app.get_parameters_from_feature_dict", return_value={}) as mock_params, - patch("controllers.web.app.fields.Parameters") as mock_fields, - ): - mock_fields.model_validate.return_value.model_dump.return_value = {} - AppParameterApi().get(app_model, SimpleNamespace()) - - mock_params.assert_called_once_with(features_dict=features_dict, user_input_form=[{"var": "x"}]) - - def test_advanced_chat_mode_no_workflow_raises(self, app: Flask) -> None: - app_model = SimpleNamespace( - mode="advanced-chat", - workflow_with_session=lambda *, session: None, - ) with app.test_request_context("/parameters"): - with pytest.raises(AppUnavailableError): - AppParameterApi().get(app_model, SimpleNamespace()) - - def test_standard_mode_uses_app_model_config(self, app: Flask) -> None: - config = SimpleNamespace(to_dict=lambda **_kwargs: {"user_input_form": [{"var": "y"}], "key": "val"}) - app_model = SimpleNamespace( - id="app-1", - mode="chat", - app_model_config_with_session=lambda *, session: config, - ) - - with ( - app.test_request_context("/parameters"), - patch("controllers.web.app.get_parameters_from_feature_dict", return_value={}) as mock_params, - patch("controllers.web.app.fields.Parameters") as mock_fields, - patch("controllers.web.app.load_annotation_reply_config", return_value={"enabled": False}), - ): - mock_fields.model_validate.return_value.model_dump.return_value = {} - AppParameterApi().get(app_model, SimpleNamespace()) - - call_kwargs = mock_params.call_args - assert call_kwargs.kwargs["user_input_form"] == [{"var": "y"}] - - def test_standard_mode_no_config_raises(self, app: Flask) -> None: - app_model = SimpleNamespace( - mode="chat", - app_model_config_with_session=lambda *, session: None, - ) - with app.test_request_context("/parameters"): - with pytest.raises(AppUnavailableError): - AppParameterApi().get(app_model, SimpleNamespace()) - - def test_agent_mode_unpublished_raises_friendly_error(self, app: Flask) -> None: - app_model = SimpleNamespace(mode="agent") - with ( - app.test_request_context("/parameters"), - patch( - "controllers.web.app.get_published_agent_app_feature_dict_and_user_input_form", - side_effect=AgentAppNotPublishedError("Agent has not been published"), - ), - ): - with pytest.raises(AgentNotPublishedError): - AppParameterApi().get(app_model, SimpleNamespace()) + with pytest.raises(http_error): + AppParameterApi().get(SimpleNamespace(id="app-1"), SimpleNamespace()) # --------------------------------------------------------------------------- diff --git a/api/tests/unit_tests/pyrefly.toml b/api/tests/unit_tests/pyrefly.toml index 5d9d0bf101d..76d1a8c5e76 100644 --- a/api/tests/unit_tests/pyrefly.toml +++ b/api/tests/unit_tests/pyrefly.toml @@ -28,7 +28,7 @@ project-excludes = [ "configs/test_dify_config.py", "configs/test_env_consistency.py", "configs/test_nacos_http_client.py", - "controllers/common/test_agent_app_parameters.py", + "repositories/test_app_definition_query_repository_agent.py", "controllers/common/test_app_access.py", "controllers/common/test_errors.py", "controllers/common/test_fields.py", diff --git a/api/tests/unit_tests/repositories/test_app_definition_query_repository.py b/api/tests/unit_tests/repositories/test_app_definition_query_repository.py index f2bf738f07a..000ee3f8103 100644 --- a/api/tests/unit_tests/repositories/test_app_definition_query_repository.py +++ b/api/tests/unit_tests/repositories/test_app_definition_query_repository.py @@ -145,6 +145,44 @@ def test_get_published_parameter_config_returns_none_for_app_without_published_m assert repository.get_published_parameter_config(_APP_ID) is None +def test_get_published_parameter_config_preserves_legacy_agent_model_config( + sqlite_session_factory: sessionmaker[Session], +) -> None: + with sqlite_session_factory() as session: + app = _persist_app(session, mode=AppMode.AGENT) + app_model_config = AppModelConfig(app_id=app.id, opening_statement="Legacy Agent config") + session.add(app_model_config) + session.flush() + app.app_model_config_id = app_model_config.id + session.commit() + + result = AppDefinitionQueryRepository(session_factory=sqlite_session_factory).get_published_parameter_config( + _APP_ID + ) + + assert result is not None + assert result.features_dict["opening_statement"] == "Legacy Agent config" + + +def test_get_public_parameter_config_reuses_standard_projection( + sqlite_session_factory: sessionmaker[Session], +) -> None: + with sqlite_session_factory() as session: + app = _persist_app(session) + app_model_config = AppModelConfig(app_id=app.id, opening_statement="Public config") + session.add(app_model_config) + session.flush() + app.app_model_config_id = app_model_config.id + session.commit() + + result = AppDefinitionQueryRepository(session_factory=sqlite_session_factory).get_published_parameter_config( + _APP_ID, public_runtime=True + ) + + assert result is not None + assert result.features_dict["opening_statement"] == "Public config" + + def _tool(provider_type: str, provider_id: str, tool_name: str) -> dict[str, object]: return { "provider_type": provider_type, diff --git a/api/tests/unit_tests/controllers/common/test_agent_app_parameters.py b/api/tests/unit_tests/repositories/test_app_definition_query_repository_agent.py similarity index 76% rename from api/tests/unit_tests/controllers/common/test_agent_app_parameters.py rename to api/tests/unit_tests/repositories/test_app_definition_query_repository_agent.py index 7b9e43143bc..7cab92eedec 100644 --- a/api/tests/unit_tests/controllers/common/test_agent_app_parameters.py +++ b/api/tests/unit_tests/repositories/test_app_definition_query_repository_agent.py @@ -3,9 +3,9 @@ from typing import Any from uuid import NAMESPACE_URL, uuid5 import pytest -from sqlalchemy.orm import Session +from sqlalchemy import select +from sqlalchemy.orm import Session, sessionmaker -from controllers.common.agent_app_parameters import get_published_agent_app_feature_dict_and_user_input_form from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError from models.agent import ( @@ -17,7 +17,11 @@ from models.agent import ( AgentSource, AgentStatus, ) -from models.model import AppAnnotationSetting +from models.model import App, AppAnnotationSetting, AppMode +from repositories.app_definition_query_repository import ( + AppDefinitionQueryRepository, + _get_public_agent_parameter_config, +) def _stable_uuid(value: str) -> str: @@ -28,7 +32,19 @@ def _app_model(*, tenant_id: str, bound_agent_id: str | None, app_model_config: return SimpleNamespace( id=_stable_uuid(f"app:{tenant_id}"), tenant_id=tenant_id, - bound_agent_id=bound_agent_id, + agent_app_binding_with_session=lambda *, session: ( + session.scalar( + select(Agent) + .where( + Agent.id == bound_agent_id, + Agent.tenant_id == tenant_id, + Agent.status == AgentStatus.ACTIVE, + ) + .limit(1) + ) + if bound_agent_id + else None + ), app_model_config_with_session=lambda *, session: app_model_config, ) @@ -40,6 +56,7 @@ def _persist_agent( agent_id: str, active_config_snapshot_id: str | None, active_config_is_published: bool, + app_id: str | None = None, ) -> Agent: agent = Agent( id=agent_id, @@ -50,6 +67,7 @@ def _persist_agent( status=AgentStatus.ACTIVE, active_config_snapshot_id=active_config_snapshot_id, active_config_is_published=active_config_is_published, + app_id=app_id, ) session.add(agent) session.commit() @@ -107,6 +125,54 @@ def _persist_publish_revision( session.commit() +def test_get_public_parameter_config_loads_agent_snapshot( + sqlite_session_factory: sessionmaker[Session], +) -> None: + tenant_id = _stable_uuid("tenant:repository") + app_id = _stable_uuid("app:repository") + agent_id = _stable_uuid("agent:repository") + snapshot_id = _stable_uuid("snapshot:repository") + with sqlite_session_factory() as session: + session.add( + App( + id=app_id, + tenant_id=tenant_id, + name="Agent App", + description="", + mode=AppMode.AGENT, + icon_type=None, + icon=None, + icon_background=None, + enable_site=True, + enable_api=True, + max_active_requests=None, + ) + ) + session.commit() + _persist_agent( + session, + tenant_id=tenant_id, + agent_id=agent_id, + active_config_snapshot_id=snapshot_id, + active_config_is_published=True, + app_id=app_id, + ) + _persist_snapshot( + session, + snapshot_id=snapshot_id, + tenant_id=tenant_id, + agent_id=agent_id, + config_snapshot={"app_variables": [{"name": "topic", "type": "string", "required": True}]}, + ) + + result = AppDefinitionQueryRepository(session_factory=sqlite_session_factory).get_published_parameter_config( + app_id, public_runtime=True + ) + + assert result is not None + assert result.user_input_form == [{"text-input": {"label": "topic", "variable": "topic", "required": True}}] + + @pytest.mark.parametrize( "sqlite_session", [(Agent, AgentConfigSnapshot, AgentConfigRevision, AppAnnotationSetting)], @@ -157,7 +223,7 @@ def test_published_agent_app_parameters_use_soul_file_upload(sqlite_session: Ses }, ) - features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form( + features_dict, user_input_form = _get_public_agent_parameter_config( app_model, session=sqlite_session, ) @@ -181,50 +247,24 @@ def test_published_agent_app_parameters_requires_bound_agent(sqlite_session: Ses app_model = _app_model(tenant_id=tenant_id, bound_agent_id=None) with pytest.raises(AgentAppGeneratorError, match="no bound Agent"): - get_published_agent_app_feature_dict_and_user_input_form(app_model, session=sqlite_session) + _get_public_agent_parameter_config(app_model, session=sqlite_session) @pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot, AgentConfigRevision)], indirect=True) -def test_published_agent_app_parameters_requires_existing_active_agent(sqlite_session: Session): - requested_tenant_id = _stable_uuid("tenant:requested") - agent_id = _stable_uuid("agent:cross-tenant") - app_model = _app_model(tenant_id=requested_tenant_id, bound_agent_id=agent_id) - _persist_agent( - sqlite_session, - tenant_id=_stable_uuid("tenant:other"), - agent_id=agent_id, - active_config_snapshot_id=None, - active_config_is_published=False, - ) - - with pytest.raises(AgentAppGeneratorError, match="no bound Agent"): - get_published_agent_app_feature_dict_and_user_input_form(app_model, session=sqlite_session) - - -@pytest.mark.parametrize( - "active_config_is_published", - [ - True, - False, - ], -) -@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot, AgentConfigRevision)], indirect=True) -def test_published_agent_app_parameters_requires_published_agent( - active_config_is_published: bool, sqlite_session: Session -): - tenant_id = _stable_uuid(f"tenant:published:{active_config_is_published}") - agent_id = _stable_uuid(f"agent:published:{active_config_is_published}") +def test_published_agent_app_parameters_requires_published_agent(sqlite_session: Session): + tenant_id = _stable_uuid("tenant:published") + agent_id = _stable_uuid("agent:published") app_model = _app_model(tenant_id=tenant_id, bound_agent_id=agent_id) _persist_agent( sqlite_session, tenant_id=tenant_id, agent_id=agent_id, active_config_snapshot_id=None, - active_config_is_published=active_config_is_published, + active_config_is_published=True, ) with pytest.raises(AgentAppNotPublishedError, match="not been published"): - get_published_agent_app_feature_dict_and_user_input_form(app_model, session=sqlite_session) + _get_public_agent_parameter_config(app_model, session=sqlite_session) @pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot, AgentConfigRevision)], indirect=True) @@ -248,7 +288,7 @@ def test_published_agent_app_parameters_allows_unpublished_draft_with_active_sna config_snapshot={}, ) - features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form( + features_dict, user_input_form = _get_public_agent_parameter_config( app_model, session=sqlite_session, ) @@ -280,7 +320,7 @@ def test_published_agent_app_parameters_rejects_seeded_unpublished_snapshot(sqli ) with pytest.raises(AgentAppNotPublishedError, match="not been published"): - get_published_agent_app_feature_dict_and_user_input_form(app_model, session=sqlite_session) + _get_public_agent_parameter_config(app_model, session=sqlite_session) @pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot, AgentConfigRevision)], indirect=True) @@ -303,7 +343,7 @@ def test_published_agent_app_parameters_requires_published_snapshot(sqlite_sessi ) with pytest.raises(AgentAppGeneratorError, match="published version not found"): - get_published_agent_app_feature_dict_and_user_input_form(app_model, session=sqlite_session) + _get_public_agent_parameter_config(app_model, session=sqlite_session) @pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot, AgentConfigRevision)], indirect=True) @@ -327,7 +367,7 @@ def test_published_agent_app_parameters_allows_missing_legacy_app_model_config(s config_snapshot={}, ) - features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form( + features_dict, user_input_form = _get_public_agent_parameter_config( app_model, session=sqlite_session, ) diff --git a/api/tests/unit_tests/services/test_app_definition_query_service.py b/api/tests/unit_tests/services/test_app_definition_query_service.py index 74c73baa77f..fc33dbacdb8 100644 --- a/api/tests/unit_tests/services/test_app_definition_query_service.py +++ b/api/tests/unit_tests/services/test_app_definition_query_service.py @@ -3,7 +3,9 @@ from unittest.mock import MagicMock, create_autospec, patch import pytest import services.app_definition_query_service as module +from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError from services.app_definition_query_service import ( + AppDefinitionNotPublishedError, AppDefinitionQuery, AppDefinitionQueryService, AppDefinitionUnavailableError, @@ -53,6 +55,37 @@ def test_get_parameters_rejects_missing_config() -> None: service.get_parameters("app-1") +def test_get_public_parameters_maps_public_config() -> None: + service, definitions = _service() + config = AppParameterConfig( + features_dict={"opening_statement": "Hello"}, + user_input_form=[{"text-input": {"variable": "query"}}], + ) + definitions.get_published_parameter_config.return_value = config + mapped = {"mapped": True} + + with patch.object(module, "get_parameters_from_feature_dict", return_value=mapped): + result = service.get_public_parameters("app-1") + + assert result is mapped + definitions.get_published_parameter_config.assert_called_once_with("app-1", public_runtime=True) + + +@pytest.mark.parametrize( + ("source_error", "expected_error"), + [ + pytest.param(AgentAppNotPublishedError("not published"), AppDefinitionNotPublishedError, id="not-published"), + pytest.param(AgentAppGeneratorError("unavailable"), AppDefinitionUnavailableError, id="unavailable"), + ], +) +def test_get_public_parameters_maps_agent_errors(source_error: Exception, expected_error: type[Exception]) -> None: + service, definitions = _service() + definitions.get_published_parameter_config.side_effect = source_error + + with pytest.raises(expected_error): + service.get_public_parameters("app-1") + + def test_get_tool_icons_maps_builtin_and_api_icons() -> None: service, definitions = _service() definitions.get_tool_icon_sources.return_value = (