refactor(api): unify message suggested questions across API surfaces (#43440)

This commit is contained in:
非法操作 authored and GitHub committed 2026-10-26 08:12:51 +00:00
1 parent a394d04443
commit a0c3901d39
64 files changed
+5900 -4011

No files matched your search

+26 -1
View File
@@ -999,7 +999,7 @@ forbidden_modules =
werkzeug
[importlinter:contract:message-suggested-questions-boundary]
name = Message suggested questions port is framework and persistence neutral
name = Message suggested questions use case is framework and persistence neutral
type = forbidden
source_modules =
services.message_suggested_questions_service
@@ -1013,6 +1013,31 @@ forbidden_modules =
sqlalchemy
werkzeug
[importlinter:contract:message-suggested-questions-legacy-boundary]
name = Suggested questions do not return to the legacy message or generation service
type = forbidden
source_modules =
services.message_suggested_questions_service
services.message_suggested_questions_queries
services.message_suggested_questions_generator
repositories.message_repository
forbidden_modules =
services.message_service
services.app_generate_service
allow_indirect_imports = True
[importlinter:contract:message-suggested-questions-persistence-boundary]
name = Suggested questions persistence does not call concrete configuration services
type = forbidden
source_modules =
repositories.message_repository
forbidden_modules =
services.conversation_service
services.workflow_service
services.agent.runtime_config_service
services.agent.roster_service
allow_indirect_imports = True
[importlinter:contract:billing-application-boundary]
name = Billing application services and ports are framework and infrastructure neutral
type = forbidden
+20 -10
View File
@@ -6,9 +6,11 @@ from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from sqlalchemy import exists, func, select
from sqlalchemy.orm import Session
from werkzeug.exceptions import HTTPException, InternalServerError, NotFound, Unauthorized
from werkzeug.exceptions import NotFound
from controllers.common.controller_schemas import MessageFeedbackPayload as _MessageFeedbackPayloadBase
from controllers.common.errors import InternalServerError as InternalServerHTTPError
from controllers.common.errors import NotFoundError, UnauthorizedError
from controllers.common.fields import SimpleResultResponse
from controllers.common.rbac import AgentId, PlainApp, RBACCheck
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
@@ -60,10 +62,14 @@ from services.app.agent_app_contracts import AgentAppNotFoundError
from services.app.console_service import ConsoleAppNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.conversation_service import ConversationService
from services.entities.message_entities import MessageAccount
from services.errors.conversation import ConversationNotExistsError
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
from services.errors.message import (
MessageActorNotFoundError,
MessageNotExistsError,
SuggestedQuestionsAfterAnswerDisabledError,
)
from services.message_service import attach_message_extra_contents
from services.message_suggested_questions_service import SuggestedQuestionsAccount, SuggestedQuestionsActorNotFoundError
logger = logging.getLogger(__name__)
@@ -278,6 +284,7 @@ class MessageAnnotationCountApi(Resource):
@console_ns.route("/apps/<uuid:app_id>/chat-messages/<uuid:message_id>/suggested-questions")
class MessageSuggestedQuestionApi(Resource):
@error_responses(
AgentVersionNotFoundError,
AppNotFoundError,
AppSuggestedQuestionsAfterAnswerDisabledError,
AppUnavailableError,
@@ -522,17 +529,18 @@ def _get_message_suggested_questions(
app_id=app_id,
app_owner_tenant_id=context.active_workspace_id,
expected_app_mode=app_mode,
actor=SuggestedQuestionsAccount(account_id=context.account_id, invoke_from="debugger"),
actor=MessageAccount(account_id=context.account_id),
invoke_from="debugger",
message_id=str(message_id),
)
except AppDefinitionUnavailableError as exc:
raise AppUnavailableError() from exc
except SuggestedQuestionsActorNotFoundError as exc:
raise Unauthorized("Account no longer exists") from exc
except MessageActorNotFoundError as exc:
raise UnauthorizedError("Account no longer exists") from exc
except MessageNotExistsError as caught_error:
raise NotFound("Message not found") from caught_error
raise NotFoundError("Message not found") from caught_error
except ConversationNotExistsError as caught_error:
raise NotFound("Conversation not found") from caught_error
raise NotFoundError("Conversation not found") from caught_error
except ProviderTokenNotInitError as ex:
raise ProviderNotInitializeError(ex.description) from ex
except QuotaExceededError as caught_error:
@@ -543,11 +551,13 @@ def _get_message_suggested_questions(
raise CompletionRequestError(e.description) from e
except SuggestedQuestionsAfterAnswerDisabledError as caught_error:
raise AppSuggestedQuestionsAfterAnswerDisabledError() from caught_error
except HTTPException:
except AgentVersionNotFoundError:
# The legacy Agent config reader still owns this HTTP error.
# Remove this compatibility case when it exposes a domain error.
raise
except Exception as caught_error:
logger.exception("internal server error.")
raise InternalServerError() from caught_error
raise InternalServerHTTPError() from caught_error
return dump_response(SuggestedQuestionsResponse, {"data": questions})
+5 -3
View File
@@ -47,18 +47,19 @@ from libs.external_api import error_responses
from machinery.context import RequestContext
from services.account_errors import AccountNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.entities.message_entities import MessageAccount
from services.errors.app import MoreLikeThisDisabledError
from services.errors.base import BaseServiceError
from services.errors.conversation import ConversationNotExistsError
from services.errors.message import (
FirstMessageNotExistsError,
MessageActorNotFoundError,
MessageNotExistsError,
SuggestedQuestionsAfterAnswerDisabledError,
)
from services.installed_app_access_service import InstalledAppNotFoundError, InstalledAppRef
from services.installed_app_generation_service import InstalledAppNotCompletionError
from services.installed_app_message_service import FeedbackRatingRequiredError, MessageNotChatAppError
from services.message_suggested_questions_service import SuggestedQuestionsAccount, SuggestedQuestionsActorNotFoundError
logger = logging.getLogger(__name__)
@@ -85,7 +86,7 @@ def _message_errors[**P, R](view: Callable[P, R]) -> Callable[P, R]:
raise InstalledAppNotFoundHTTPError() from error
except AppDefinitionUnavailableError as error:
raise AppUnavailableError() from error
except (AccountNotFoundError, SuggestedQuestionsActorNotFoundError) as error:
except (AccountNotFoundError, MessageActorNotFoundError) as error:
raise UnauthorizedError("Account no longer exists.") from error
except MessageNotChatAppError as error:
raise NotChatAppError() from error
@@ -243,7 +244,8 @@ class MessageSuggestedQuestionApi(Resource):
app_id=installed_app.app_id,
app_owner_tenant_id=installed_app.app_owner_tenant_id,
expected_app_mode=installed_app.app_mode,
actor=SuggestedQuestionsAccount(account_id=request_context.account_id, invoke_from="explore"),
actor=MessageAccount(account_id=request_context.account_id),
invoke_from="explore",
message_id=str(message_id),
)
except (
+8 -3
View File
@@ -120,6 +120,7 @@ from services.app_preview_query_service import (
AppPreviewUnavailableError,
)
from services.audio_types import AudioAppRef, AudioUpload
from services.entities.message_entities import MessageAccount
from services.errors.audio import (
AudioTooLargeServiceError,
NoAudioUploadedServiceError,
@@ -131,11 +132,11 @@ from services.errors.audio import (
from services.errors.conversation import ConversationNotExistsError
from services.errors.llm import InvokeRateLimitError
from services.errors.message import (
MessageActorNotFoundError,
MessageNotExistsError,
SuggestedQuestionsAfterAnswerDisabledError,
)
from services.file_service import FileUploadActor
from services.message_suggested_questions_service import SuggestedQuestionsAccount, SuggestedQuestionsActorNotFoundError
from services.trial_app_access_service import TrialAppRef
from services.trial_app_generation_service import (
TrialAppNotChatError,
@@ -732,6 +733,7 @@ class TrialChatTaskStopApi(Resource):
)
class TrialMessageSuggestedQuestionApi(Resource):
@error_responses(
AgentVersionNotFoundHTTPError,
AppSuggestedQuestionsAfterAnswerDisabledError,
AppUnavailableError,
CompletionRequestError,
@@ -752,12 +754,13 @@ class TrialMessageSuggestedQuestionApi(Resource):
app_id=trial_app.app_id,
app_owner_tenant_id=trial_app.tenant_id,
expected_app_mode=trial_app.app_mode,
actor=SuggestedQuestionsAccount(account_id=request_context.account_id, invoke_from="explore"),
actor=MessageAccount(account_id=request_context.account_id),
invoke_from="explore",
message_id=str(message_id),
)
except AppDefinitionUnavailableError as error:
raise AppUnavailableError() from error
except SuggestedQuestionsActorNotFoundError as error:
except MessageActorNotFoundError as error:
raise UnauthorizedError("Account no longer exists.") from error
except MessageNotExistsError as caught_error:
raise NotFoundError("Message not found") from caught_error
@@ -773,6 +776,8 @@ class TrialMessageSuggestedQuestionApi(Resource):
raise ProviderModelCurrentlyNotSupportError() from caught_error
except InvokeError as e:
raise CompletionRequestError(e.description) from e
except AgentVersionNotFoundError as error:
raise AgentVersionNotFoundHTTPError() from error
except Exception as caught_error:
logger.exception("internal server error.")
raise InternalServerError() from caught_error
+38
View File
@@ -1,3 +1,5 @@
from http import HTTPStatus
from libs.exception import BaseHTTPException
from services.errors.app import (
TRIGGER_WORKFLOW_SERVICE_MODE_UNAVAILABLE_CODE,
@@ -5,6 +7,36 @@ from services.errors.app import (
)
class AppNotFoundError(BaseHTTPException):
error_code = "app_not_found"
description = "The app no longer exists."
code = HTTPStatus.FORBIDDEN
class AppAbnormalStatusError(BaseHTTPException):
error_code = "app_abnormal_status"
description = "The app's status is abnormal."
code = HTTPStatus.FORBIDDEN
class AppApiDisabledError(BaseHTTPException):
error_code = "app_api_disabled"
description = "The app's API service has been disabled."
code = HTTPStatus.FORBIDDEN
class WorkspaceNotFoundError(BaseHTTPException):
error_code = "workspace_not_found"
description = "Tenant does not exist."
code = HTTPStatus.FORBIDDEN
class WorkspaceArchivedError(BaseHTTPException):
error_code = "workspace_archived"
description = "The workspace's status is archived."
code = HTTPStatus.FORBIDDEN
class AppUnavailableError(BaseHTTPException):
error_code = "app_unavailable"
description = "App unavailable, please check your app configurations."
@@ -83,6 +115,12 @@ class CompletionRequestError(BaseHTTPException):
code = 400
class AppSuggestedQuestionsAfterAnswerDisabledError(BaseHTTPException):
error_code = "app_suggested_questions_after_answer_disabled"
description = "The 'Suggested Questions After Answer' feature is disabled."
code = 403
class NoAudioUploadedError(BaseHTTPException):
error_code = "no_audio_uploaded"
description = "Please upload your audio."
+96 -37
View File
@@ -1,30 +1,49 @@
import logging
from http import HTTPStatus
from typing import Annotated
from uuid import UUID
from flask_restx import Resource
from pydantic import BaseModel, Field, TypeAdapter, WithJsonSchema
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
import services
from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery
from controllers.common.errors import InternalServerError, NotFoundError
from controllers.common.fields import SimpleResultStringListResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console.wraps import model_validate
from controllers.service_api import service_api_ns
from controllers.service_api.app.error import NotChatAppError
from controllers.service_api.app.error import (
AppSuggestedQuestionsAfterAnswerDisabledError,
AppUnavailableError,
CompletionRequestError,
NotChatAppError,
ProviderModelCurrentlyNotSupportError,
ProviderNotInitializeError,
ProviderQuotaExceededError,
)
from controllers.service_api.flask_admission import service_api_end_user_admission
from controllers.service_api.schema import expect_with_user
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from fields.base import ResponseModel
from fields.conversation_fields import MessageResponseSource, ResultResponse
from fields.message_fields import MessageInfiniteScrollPagination, MessageListItem
from graphon.model_runtime.errors.invoke import InvokeError
from libs.external_api import error_responses
from libs.helper import dump_response
from machinery.context import ServiceApiEndUserContext
from models.enums import FeedbackRating
from models.model import App, AppMode, EndUser
from services.agent.errors import AgentVersionNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.entities.message_entities import MessageEndUser
from services.errors.conversation import ConversationNotExistsError
from services.errors.message import (
FirstMessageNotExistsError,
MessageActorNotFoundError,
MessageNotExistsError,
SuggestedQuestionsAfterAnswerDisabledError,
)
@@ -130,9 +149,9 @@ class MessageListApi(Resource):
limit=pagination.limit, has_more=pagination.has_more, data=items
).model_dump(mode="json")
except services.errors.conversation.ConversationNotExistsError as caught_error:
raise NotFound("Conversation Not Exists.") from caught_error
raise NotFoundError("Conversation Not Exists.") from caught_error
except FirstMessageNotExistsError as caught_error:
raise NotFound("First Message Not Exists.") from caught_error
raise NotFoundError("First Message Not Exists.") from caught_error
@service_api_ns.route("/messages/<uuid:message_id>/feedbacks")
@@ -180,7 +199,7 @@ class MessageFeedbackApi(Resource):
session=db.session(),
)
except MessageNotExistsError as caught_error:
raise NotFound("Message Not Exists.") from caught_error
raise NotFoundError("Message Not Exists.") from caught_error
return ResultResponse(result="success").model_dump(mode="json")
@@ -227,63 +246,103 @@ class AppGetFeedbacksApi(Resource):
@service_api_ns.route("/messages/<uuid:message_id>/suggested")
class MessageSuggestedApi(Resource):
@error_responses(NotChatAppError)
@error_responses(
AgentVersionNotFoundError,
AppSuggestedQuestionsAfterAnswerDisabledError,
AppUnavailableError,
CompletionRequestError,
NotChatAppError,
ProviderModelCurrentlyNotSupportError,
ProviderNotInitializeError,
ProviderQuotaExceededError,
)
@service_api_ns.doc("get_suggested_questions")
@service_api_ns.doc(
summary="Get Next Suggested Questions",
description="Get next questions suggestions for the current message.",
description=(
"Get next question suggestions for the current message. "
"If no usable model can be resolved or the model call to generate questions fails, "
"the response is HTTP 200 with an empty data list. "
"Model invocation failures during history token counting instead return "
"HTTP 400 with `completion_request_error`."
),
tags=["Chats", "Chatflows"],
responses={
200: "Successfully retrieved suggested questions.",
400: (
HTTPStatus.OK: "Suggested questions retrieved successfully",
HTTPStatus.BAD_REQUEST: (
"- `not_chat_app` : App mode does not match the API route.\n"
"- `bad_request` : Suggested questions feature is disabled."
"- `app_unavailable` : App is no longer available.\n"
"- `completion_request_error` : Model invocation failed while counting history tokens."
),
404: "`not_found` : Message does not exist.",
500: "`internal_server_error` : Internal server error.",
HTTPStatus.UNAUTHORIZED: "Unauthorized - invalid API token",
HTTPStatus.FORBIDDEN: (
"- `forbidden` : Token scope does not allow access.\n"
"- `app_not_found` : The token's app no longer exists.\n"
"- `app_abnormal_status` : App status does not allow API access.\n"
"- `app_api_disabled` : The app's API service has been disabled.\n"
"- `workspace_not_found` : The app's workspace no longer exists.\n"
"- `workspace_archived` : The app's workspace is archived.\n"
"- `app_suggested_questions_after_answer_disabled` : Suggested questions feature is disabled."
),
HTTPStatus.NOT_FOUND: (
"- `not_found` : End user, message, or conversation does not exist.\n"
"- `agent_version_not_found_error` : Agent config version does not exist."
),
HTTPStatus.INTERNAL_SERVER_ERROR: "`internal_server_error` : Internal server error.",
},
)
@service_api_ns.response(
200,
HTTPStatus.OK,
"Suggested questions retrieved successfully",
service_api_ns.models[SimpleResultStringListResponse.__name__],
)
@service_api_ns.doc(description="Get suggested follow-up questions for a message")
@service_api_ns.doc(params={"message_id": "Message ID"})
@service_api_ns.doc(
responses={
200: "Suggested questions retrieved successfully",
400: "Suggested questions feature is disabled",
401: "Unauthorized - invalid API token",
404: "Message not found",
500: "Internal server error",
}
)
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY, required=True))
def get(self, app_model: App, end_user: EndUser, message_id: UUID):
@service_api_end_user_admission(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY, required=True))
def get(self, context: ServiceApiEndUserContext, message_id: UUID) -> dict[str, object]:
"""Get suggested follow-up questions for a message.
Returns AI-generated follow-up questions based on the message content.
Returns an empty list when model resolution or the question-generation
model call fails. History token-counting invocation failures return
HTTP 400 with completion_request_error.
"""
message_id_str = str(message_id)
app_mode = AppMode.value_of(app_model.mode)
app_mode = AppMode.value_of(context.app_mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
raise NotChatAppError()
try:
questions = MessageService.get_suggested_questions_after_answer(
app_model=app_model,
user=end_user,
questions = application_services().message_suggested_questions.get_suggested_questions(
app_id=context.app_id,
app_owner_tenant_id=context.tenant_id,
expected_app_mode=context.app_mode,
actor=MessageEndUser(end_user_id=context.end_user_id),
invoke_from="service-api",
message_id=message_id_str,
invoke_from=InvokeFrom.SERVICE_API,
session=db.session(),
)
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
except MessageActorNotFoundError:
raise NotFoundError("End user not found") from None
except MessageNotExistsError as caught_error:
raise NotFound("Message Not Exists.") from caught_error
except SuggestedQuestionsAfterAnswerDisabledError as caught_error:
raise BadRequest("Suggested Questions Is Disabled.") from caught_error
raise NotFoundError("Message Not Exists.") from caught_error
except ConversationNotExistsError:
raise NotFoundError("Conversation not found") from None
except SuggestedQuestionsAfterAnswerDisabledError:
raise AppSuggestedQuestionsAfterAnswerDisabledError() from None
except ProviderTokenNotInitError as error:
raise ProviderNotInitializeError(error.description) from error
except QuotaExceededError:
raise ProviderQuotaExceededError() from None
except ModelCurrentlyNotSupportError:
raise ProviderModelCurrentlyNotSupportError() from None
except InvokeError as error:
raise CompletionRequestError(error.description) from error
except AgentVersionNotFoundError:
# The legacy Agent config reader still owns this HTTP error.
# Remove this compatibility case when it exposes a domain error.
raise
except Exception as caught_error:
logger.exception("internal server error.")
raise InternalServerError() from caught_error
return SimpleResultStringListResponse(result="success", data=questions).model_dump(mode="json")
return dump_response(SimpleResultStringListResponse, {"result": "success", "data": questions})
@@ -0,0 +1,94 @@
"""Service API admission for handlers that consume stable app and end-user values."""
from collections.abc import Callable
from functools import wraps
from typing import Concatenate
from flask import request
from controllers.common.resource_access_token_errors import resource_access_token_errors
from controllers.service_api.app import error as http_errors
from controllers.service_api.wraps import (
FetchUserArg,
document_app_token_contract,
peek_service_api_bearer_token,
resolve_service_api_end_user,
validate_and_get_api_token,
)
from extensions.ext_application_services import application_services
from libs.external_api import document_errors
from machinery.context import ServiceApiEndUserContext
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.auth.resource_access_token_contracts import is_resource_access_token
from services.errors.app import AppAbnormalStatusError, AppApiDisabledError
from services.errors.workspace import WorkspaceArchivedError, WorkspaceNotFoundError
def service_api_end_user_admission[T, **P, R](
*,
fetch_user_arg: FetchUserArg,
) -> Callable[
[Callable[Concatenate[T, ServiceApiEndUserContext, P], R]],
Callable[Concatenate[T, P], R],
]:
"""Validate API access before provisioning an end user and injecting scalar identity.
App/workspace reads finish before user provisioning or the handler runs. The
shared login helper retains Flask's request identity for legacy dependencies;
neither an ORM object nor its session is passed to the handler.
"""
def decorator(
view: Callable[Concatenate[T, ServiceApiEndUserContext, P], R],
) -> Callable[Concatenate[T, P], R]:
@wraps(view)
def admitted(self: T, /, *args: P.args, **kwargs: P.kwargs) -> R:
auth_token = peek_service_api_bearer_token()
tenant_id = None
if auth_token and is_resource_access_token(auth_token):
with resource_access_token_errors():
grant = application_services().resource_access_tokens.resolve_app_for_service_api(
token=auth_token,
requested_app_id=request.headers.get("X-Dify-App-ID"),
)
app_id = next(iter(grant.app_ids))
tenant_id = grant.tenant_id
else:
token = validate_and_get_api_token("app")
app_id = token.app_id
try:
app = application_services().app_definitions.get_service_api_app(app_id, tenant_id=tenant_id)
except AppDefinitionUnavailableError as error:
raise http_errors.AppNotFoundError() from error
except AppAbnormalStatusError as error:
raise http_errors.AppAbnormalStatusError() from error
except AppApiDisabledError as error:
raise http_errors.AppApiDisabledError() from error
except WorkspaceNotFoundError as error:
raise http_errors.WorkspaceNotFoundError() from error
except WorkspaceArchivedError as error:
raise http_errors.WorkspaceArchivedError() from error
end_user = resolve_service_api_end_user(
tenant_id=app.tenant_id, app_id=app.app_id, fetch_user_arg=fetch_user_arg
)
context = ServiceApiEndUserContext(
tenant_id=app.tenant_id,
app_id=app.app_id,
app_mode=app.mode,
end_user_id=end_user.id,
)
return view(self, context, *args, **kwargs)
document_app_token_contract(admitted, fetch_user_arg)
document_errors(
admitted,
http_errors.AppNotFoundError,
http_errors.AppAbnormalStatusError,
http_errors.AppApiDisabledError,
http_errors.WorkspaceNotFoundError,
http_errors.WorkspaceArchivedError,
)
return admitted
return decorator
+31 -27
View File
@@ -29,7 +29,7 @@ from extensions.ext_database import db
from libs.login import current_user
from models import Account, Tenant, TenantAccountJoin, TenantStatus
from models.dataset import Dataset
from models.model import ApiToken
from models.model import ApiToken, EndUser
from repositories.knowledge import dataset_api_key_bindings
from services.api_token_service import ApiTokenCache, fetch_token_with_single_flight, record_token_usage
from services.app_service import AppService
@@ -77,7 +77,7 @@ VECTOR_SPACE_UNAVAILABLE_RESPONSE = {
}
def _document_app_token_contract(view_func: Callable[..., object], fetch_user_arg: FetchUserArg | None) -> None:
def document_app_token_contract(view_func: Callable[..., object], fetch_user_arg: FetchUserArg | None) -> None:
doc: dict[str, object] = {"responses": APP_TOKEN_FORBIDDEN_RESPONSE}
if fetch_user_arg is not None:
setattr(view_func, USER_FETCH_FROM_ATTR, fetch_user_arg.fetch_from.name)
@@ -112,6 +112,10 @@ def validate_app_token[**P, R](
def decorator(view_func: Callable[P, R]) -> Callable[P, R]:
@wraps(view_func)
def decorated_view(*args: P.args, **kwargs: P.kwargs) -> R:
# TODO: Remove these duplicate app/workspace checks when all legacy
# ORM App/EndUser handlers (including account-only endpoints) migrate
# to scalar admission. Keep resource-token routing and app policy aligned
# with service_api_end_user_admission until then.
auth_token = peek_service_api_bearer_token()
if auth_token and is_resource_access_token(auth_token):
with resource_access_token_errors():
@@ -146,31 +150,9 @@ def validate_app_token[**P, R](
# If caller needs end-user context, attach EndUser to current_user
if fetch_user_arg:
user_id = None
match fetch_user_arg.fetch_from:
case WhereisUserArg.QUERY:
user_id = request.args.get("user")
case WhereisUserArg.JSON:
user_id = request.get_json().get("user")
case WhereisUserArg.FORM:
user_id = request.form.get("user")
if not user_id and fetch_user_arg.required:
raise ValueError("Arg user must be provided.")
if user_id:
user_id = str(user_id)
end_user = application_services().app_scoped_end_users.commands.get_or_create_end_user(
app_model.tenant_id,
app_model.id,
user_id,
kwargs["end_user"] = resolve_service_api_end_user(
tenant_id=app_model.tenant_id, app_id=app_model.id, fetch_user_arg=fetch_user_arg
)
kwargs["end_user"] = end_user
# Set EndUser as current logged-in user for flask_login.current_user
current_app.login_manager._update_request_context_with_user(end_user) # type: ignore
user_logged_in.send(current_app._get_current_object(), user=end_user) # type: ignore
else:
# For service API without end-user context, ensure an Account is logged in
# so services relying on current_account_with_tenant() work correctly.
@@ -195,7 +177,7 @@ def validate_app_token[**P, R](
return view_func(*args, **kwargs)
_document_app_token_contract(decorated_view, fetch_user_arg)
document_app_token_contract(decorated_view, fetch_user_arg)
return decorated_view
if view is None:
@@ -204,6 +186,28 @@ def validate_app_token[**P, R](
return decorator(view)
def resolve_service_api_end_user(*, tenant_id: str, app_id: str, fetch_user_arg: FetchUserArg) -> EndUser:
"""Share user parsing, provisioning and Flask login identity across API admission paths."""
user_id = None
match fetch_user_arg.fetch_from:
case WhereisUserArg.QUERY:
user_id = request.args.get("user")
case WhereisUserArg.JSON:
user_id = request.get_json().get("user")
case WhereisUserArg.FORM:
user_id = request.form.get("user")
if not user_id and fetch_user_arg.required:
raise ValueError("Arg user must be provided.")
if user_id:
user_id = str(user_id)
end_user = application_services().app_scoped_end_users.commands.get_or_create_end_user(tenant_id, app_id, user_id)
current_app.login_manager._update_request_context_with_user(end_user) # type: ignore
user_logged_in.send(current_app._get_current_object(), user=end_user) # type: ignore
return end_user
def cloud_edition_billing_resource_check[**P, R](
resource: str,
api_token_type: str,
+51 -23
View File
@@ -1,12 +1,13 @@
import logging
from http import HTTPStatus
from typing import Literal
from uuid import UUID
from pydantic import BaseModel, Field, TypeAdapter
from sqlalchemy.orm import Session
from werkzeug.exceptions import InternalServerError, NotFound
from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery
from controllers.common.errors import InternalServerError, NotFoundError
from controllers.common.fields import GeneratedAppResponse
from controllers.common.schema import query_params_from_model, register_response_schema_models, register_schema_models
from controllers.console.app.wraps import with_session
@@ -15,6 +16,7 @@ from controllers.web import web_ns
from controllers.web.error import (
AppMoreLikeThisDisabledError,
AppSuggestedQuestionsAfterAnswerDisabledError,
AppUnavailableError,
CompletionRequestError,
NotChatAppError,
NotCompletionAppError,
@@ -25,6 +27,7 @@ from controllers.web.error import (
from controllers.web.wraps import WebApiResource
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from extensions.ext_application_services import application_services
from extensions.ext_database import db
from fields.conversation_fields import MessageResponseSource, ResultResponse
from fields.message_fields import SuggestedQuestionsResponse, WebMessageInfiniteScrollPagination, WebMessageListItem
@@ -33,11 +36,15 @@ from libs import helper
from libs.external_api import error_responses
from models.enums import FeedbackRating
from models.model import App, AppMode, EndUser
from services.agent.errors import AgentVersionNotFoundError
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.app_generate_service import AppGenerateService
from services.entities.message_entities import MessageEndUser
from services.errors.app import MoreLikeThisDisabledError
from services.errors.conversation import ConversationNotExistsError
from services.errors.message import (
FirstMessageNotExistsError,
MessageActorNotFoundError,
MessageNotExistsError,
SuggestedQuestionsAfterAnswerDisabledError,
)
@@ -100,9 +107,9 @@ class MessageListApi(WebApiResource):
data=items,
).model_dump(mode="json")
except ConversationNotExistsError as caught_error:
raise NotFound("Conversation Not Exists.") from caught_error
raise NotFoundError("Conversation Not Exists.") from caught_error
except FirstMessageNotExistsError as caught_error:
raise NotFound("First Message Not Exists.") from caught_error
raise NotFoundError("First Message Not Exists.") from caught_error
@web_ns.route("/messages/<uuid:message_id>/feedbacks")
@@ -146,7 +153,7 @@ class MessageFeedbackApi(WebApiResource):
session=db.session(),
)
except MessageNotExistsError as caught_error:
raise NotFound("Message Not Exists.") from caught_error
raise NotFoundError("Message Not Exists.") from caught_error
return ResultResponse(result="success").model_dump(mode="json")
@@ -205,7 +212,7 @@ class MessageMoreLikeThisApi(WebApiResource):
# response-contract:ignore compact_generate_response
return helper.compact_generate_response(response)
except MessageNotExistsError as caught_error:
raise NotFound("Message Not Exists.") from caught_error
raise NotFoundError("Message Not Exists.") from caught_error
except MoreLikeThisDisabledError as caught_error:
raise AppMoreLikeThisDisabledError() from caught_error
except ProviderTokenNotInitError as ex:
@@ -226,28 +233,41 @@ class MessageMoreLikeThisApi(WebApiResource):
@web_ns.route("/messages/<uuid:message_id>/suggested-questions")
class MessageSuggestedQuestionApi(WebApiResource):
@error_responses(
AgentVersionNotFoundError,
AppSuggestedQuestionsAfterAnswerDisabledError,
AppUnavailableError,
CompletionRequestError,
NotChatAppError,
ProviderModelCurrentlyNotSupportError,
ProviderNotInitializeError,
ProviderQuotaExceededError,
)
@web_ns.response(200, "Success", web_ns.models[SuggestedQuestionsResponse.__name__])
@web_ns.response(HTTPStatus.OK, "Success", web_ns.models[SuggestedQuestionsResponse.__name__])
@web_ns.doc("Get Suggested Questions")
@web_ns.doc(description="Get suggested follow-up questions after a message (chat apps only).")
@web_ns.doc(
description=(
"Get suggested follow-up questions after a message (chat apps only). "
"If no usable model can be resolved or the model call to generate questions fails, "
"the response is HTTP 200 with an empty data list. "
"Model invocation failures during history token counting instead return "
"HTTP 400 with `completion_request_error`."
)
)
@web_ns.doc(params={"message_id": {"description": "Message UUID", "type": "string", "required": True}})
@web_ns.doc(
responses={
200: "Success",
400: "Bad Request - Not a chat app or feature disabled",
401: "Unauthorized",
403: "Forbidden",
404: "Message Not Found or Conversation Not Found",
500: "Internal Server Error",
HTTPStatus.OK: "Success",
HTTPStatus.BAD_REQUEST: (
"Bad Request - Not a chat app or app unavailable; "
"`completion_request_error` when model invocation fails while counting history tokens."
),
HTTPStatus.UNAUTHORIZED: "Unauthorized",
HTTPStatus.FORBIDDEN: "Forbidden - Access denied or suggested questions disabled",
HTTPStatus.NOT_FOUND: "App, End User, Message, or Conversation Not Found",
HTTPStatus.INTERNAL_SERVER_ERROR: "Internal Server Error",
}
)
def get(self, app_model: App, end_user: EndUser, message_id: UUID):
def get(self, app_model: App, end_user: EndUser, message_id: UUID) -> dict[str, object]:
app_mode = AppMode.value_of(app_model.mode)
if app_mode not in {AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT}:
raise NotChatAppError()
@@ -255,18 +275,22 @@ class MessageSuggestedQuestionApi(WebApiResource):
message_id_str = str(message_id)
try:
questions = MessageService.get_suggested_questions_after_answer(
app_model=app_model,
user=end_user,
questions = application_services().message_suggested_questions.get_suggested_questions(
app_id=app_model.id,
app_owner_tenant_id=app_model.tenant_id,
expected_app_mode=app_model.mode,
actor=MessageEndUser(end_user_id=end_user.id),
invoke_from="web-app",
message_id=message_id_str,
invoke_from=InvokeFrom.WEB_APP,
session=db.session(),
)
# questions is a list of strings, not a list of Message objects
except AppDefinitionUnavailableError:
raise AppUnavailableError() from None
except MessageActorNotFoundError:
raise NotFoundError("End user not found") from None
except MessageNotExistsError as caught_error:
raise NotFound("Message not found") from caught_error
raise NotFoundError("Message not found") from caught_error
except ConversationNotExistsError as caught_error:
raise NotFound("Conversation not found") from caught_error
raise NotFoundError("Conversation not found") from caught_error
except SuggestedQuestionsAfterAnswerDisabledError as caught_error:
raise AppSuggestedQuestionsAfterAnswerDisabledError() from caught_error
except ProviderTokenNotInitError as ex:
@@ -277,8 +301,12 @@ class MessageSuggestedQuestionApi(WebApiResource):
raise ProviderModelCurrentlyNotSupportError() from caught_error
except InvokeError as e:
raise CompletionRequestError(e.description) from e
except AgentVersionNotFoundError:
# The legacy Agent config reader still owns this HTTP error.
# Remove this compatibility case when it exposes a domain error.
raise
except Exception as caught_error:
logger.exception("internal server error.")
raise InternalServerError() from caught_error
return SuggestedQuestionsResponse(data=questions).model_dump(mode="json")
return helper.dump_response(SuggestedQuestionsResponse, {"data": questions})
+3 -180
View File
@@ -2,8 +2,7 @@ import json
import logging
import re
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Any, Literal, NotRequired, Protocol, TypedDict, cast
from typing import Any, Literal, Protocol, TypedDict, cast
import json_repair
from sqlalchemy import select
@@ -13,7 +12,6 @@ from core.app.app_config.entities import ModelConfig
from core.app.entities.app_invoke_entities import CreditUsageCreatedBy
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
from core.llm_generator.output_parser.rule_config_generator import RuleConfigGeneratorOutputParser
from core.llm_generator.output_parser.suggested_questions_after_answer import SuggestedQuestionsAfterAnswerOutputParser
from core.llm_generator.prompts import (
CONVERSATION_TITLE_PROMPT,
GENERATOR_QA_PROMPT,
@@ -25,11 +23,10 @@ from core.llm_generator.prompts import (
WORKFLOW_RULE_CONFIG_PROMPT_GENERATE_TEMPLATE,
)
from core.model_context import with_credit_usage_created_by
from core.model_manager import ModelInstance, ModelManager
from core.model_manager import ModelManager
from core.ops.entities.trace_entity import TraceTaskName
from core.ops.ops_trace_manager import TraceQueueManager, TraceTask
from core.ops.utils import measure_time
from core.plugin.impl.base import use_plugin_daemon_request_timeout
from core.prompt.utils.prompt_template_parser import PromptTemplateParser
from core.telemetry import PromptGenerationEvent, TelemetryContext
from core.telemetry import emit as telemetry_emit
@@ -38,31 +35,13 @@ from extensions.ext_storage import storage
from graphon.enums import WorkflowNodeExecutionMetadataKey
from graphon.model_runtime.entities.llm_entities import LLMResult
from graphon.model_runtime.entities.message_entities import PromptMessage, SystemPromptMessage, UserPromptMessage
from graphon.model_runtime.entities.model_entities import ModelType, ParameterType
from graphon.model_runtime.entities.model_entities import ModelType
from graphon.model_runtime.errors.invoke import InvokeError
from models import App, Message, WorkflowNodeExecutionModel
from models.workflow import Workflow
logger = logging.getLogger(__name__)
_SUGGESTED_QUESTIONS_MAX_TOKENS = 256
_SUGGESTED_QUESTIONS_TIMEOUT_SECONDS = 30.0
_LOW_REASONING_EFFORTS = ("none", "minimal", "low")
class SuggestedQuestionsModelConfig(TypedDict):
provider: str
name: str
completion_params: NotRequired[dict[str, object]]
@dataclass(frozen=True)
class PreparedSuggestedQuestionsModel:
"""Resolved model credentials; ``None`` parameters select the default-model tuning."""
model_instance: ModelInstance
completion_params: dict[str, object] | None
def _normalize_completion_params(completion_params: dict[str, object]) -> tuple[dict[str, object], list[str]]:
"""
@@ -85,39 +64,6 @@ def _normalize_completion_params(completion_params: dict[str, object]) -> tuple[
return normalized_parameters, stop
def _default_suggested_questions_model_parameters(model_instance: ModelInstance) -> dict[str, object]:
"""Build a low-latency parameter set for the workspace default model."""
parameters: dict[str, object] = {
"max_tokens": _SUGGESTED_QUESTIONS_MAX_TOKENS,
"temperature": 0.0,
}
try:
model_schema = model_instance.get_model_schema()
except Exception:
logger.warning("Failed to inspect the default model schema for suggested questions", exc_info=True)
return parameters
parameter_rules = {rule.name: rule for rule in model_schema.parameter_rules}
thinking_rule = parameter_rules.get("thinking")
if thinking_rule is not None:
if thinking_rule.type == ParameterType.BOOLEAN:
parameters["thinking"] = False
return parameters
if "disabled" in thinking_rule.options:
parameters["thinking"] = "disabled"
return parameters
reasoning_effort_rule = parameter_rules.get("reasoning_effort")
if reasoning_effort_rule is not None:
for effort in _LOW_REASONING_EFFORTS:
if effort in reasoning_effort_rule.options:
parameters["reasoning_effort"] = effort
break
return parameters
# ── Workflow instruction-suggestion tuning ────────────────────────────────
# Suggestions are a soft, pre-model-pick enhancement: short, buildable example
# instructions proposed from the tenant's DEFAULT model. Every failure path
@@ -296,129 +242,6 @@ class LLMGenerator:
return name
@classmethod
@with_credit_usage_created_by(CreditUsageCreatedBy.SUGGESTED_QUESTIONS)
def generate_suggested_questions_after_answer(
cls,
tenant_id: str,
histories: str,
*,
instruction_prompt: str | None = None,
model_config: object | None = None,
) -> Sequence[str]:
prepared_model = cls.prepare_suggested_questions_model(tenant_id, model_config=model_config)
if prepared_model is None:
return []
return cls.invoke_suggested_questions_after_answer(
prepared_model, histories, instruction_prompt=instruction_prompt
)
@classmethod
@with_credit_usage_created_by(CreditUsageCreatedBy.SUGGESTED_QUESTIONS)
def prepare_suggested_questions_model(
cls,
tenant_id: str,
*,
model_config: object | None = None,
) -> PreparedSuggestedQuestionsModel | None:
"""Resolve the model before its caller releases database sessions.
``None`` means no usable configured or default model was found. Schema
inspection and generation belong to invocation, after the read phase.
"""
try:
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
configured_model = cast(dict[str, object], model_config) if isinstance(model_config, dict) else {}
provider = configured_model.get("provider")
model_name = configured_model.get("name")
use_configured_model = False
if isinstance(provider, str) and provider and isinstance(model_name, str) and model_name:
try:
model_instance = model_manager.get_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
provider=provider,
model=model_name,
)
use_configured_model = True
except Exception:
logger.warning(
"Failed to use configured suggested-questions model %s/%s, fallback to default model",
provider,
model_name,
exc_info=True,
)
model_instance = model_manager.get_default_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
)
else:
model_instance = model_manager.get_default_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
)
except Exception:
logger.exception("Failed to resolve the suggested-questions model")
return None
completion_params: dict[str, object] | None = None
if use_configured_model:
configured_completion_params = configured_model.get("completion_params")
completion_params = (
dict(configured_completion_params) if isinstance(configured_completion_params, dict) else {}
)
return PreparedSuggestedQuestionsModel(
model_instance=model_instance,
completion_params=completion_params,
)
@classmethod
@with_credit_usage_created_by(CreditUsageCreatedBy.SUGGESTED_QUESTIONS)
def invoke_suggested_questions_after_answer(
cls,
prepared_model: PreparedSuggestedQuestionsModel,
histories: str,
*,
instruction_prompt: str | None = None,
) -> Sequence[str]:
"""Generate with an already resolved model without repeating model resolution."""
output_parser = SuggestedQuestionsAfterAnswerOutputParser(instruction_prompt=instruction_prompt)
format_instructions = output_parser.get_format_instructions()
prompt_template = PromptTemplateParser(template="{{histories}}\n{{format_instructions}}\nquestions:\n")
prompt = prompt_template.format({"histories": histories, "format_instructions": format_instructions})
prompt_messages: list[PromptMessage] = [UserPromptMessage(content=prompt)]
questions: Sequence[str] = []
try:
model_parameters: dict[str, object]
stop: list[str]
model_instance = prepared_model.model_instance
if prepared_model.completion_params is not None:
model_parameters, stop = _normalize_completion_params(prepared_model.completion_params)
else:
# Default-model generation keeps the built-in suggested-questions tuning.
model_parameters = _default_suggested_questions_model_parameters(model_instance)
stop = []
with use_plugin_daemon_request_timeout(_SUGGESTED_QUESTIONS_TIMEOUT_SECONDS):
response: LLMResult = model_instance.invoke_llm(
prompt_messages=list(prompt_messages),
model_parameters=model_parameters,
stop=stop,
stream=False,
)
text_content = response.message.get_text_content()
questions = output_parser.parse(text_content) if text_content else []
except Exception:
logger.exception("Failed to generate suggested questions after answer")
questions = []
return questions
@classmethod
@with_credit_usage_created_by(CreditUsageCreatedBy.WORKFLOW_INSTRUCTION_SUGGESTIONS)
def generate_workflow_instruction_suggestions(
+1 -1
View File
@@ -171,7 +171,7 @@ class TokenBufferMemory:
workflow = session.scalar(select(Workflow).where(Workflow.id == workflow_run.workflow_id))
if not workflow:
raise ValueError(f"Workflow not found: {workflow_run.workflow_id}")
return FileUploadConfigManager.convert(workflow.features_dict, is_vision=False)
return FileUploadConfigManager.convert(workflow.normalized_features_dict, is_vision=False)
case _:
raise AssertionError(f"Invalid app mode: {conversation.mode}")
+13 -3
View File
@@ -72,6 +72,7 @@ from repositories.file_grant_repository import FileGrantRepository
from repositories.human_input_file_upload_repository import SQLAlchemyHumanInputFileUploadRepository
from repositories.installation_state_repository import InstallationStateRepository
from repositories.message_file_preview_repository import MessageFilePreviewQueryRepository
from repositories.message_repository import MessageRepository
from repositories.network_access_group_repository import SQLAlchemyNetworkAccessGroupAppRepository
from repositories.oauth_device_token_repository import SQLAlchemyOAuthDeviceTokenRepository
from repositories.oauth_server_repository import RedisOAuthServerTokenRepository, SQLAlchemyOAuthServerRepository
@@ -144,8 +145,9 @@ from services.knowledge.api_key_service import DatasetApiKeyService
from services.knowledge_fs.builtin_tool.knowledge_fs.knowledge_fs import KnowledgeFSProvider
from services.knowledge_fs.workflow_runtime import build_knowledge_fs_node_dependencies
from services.message_file_preview_service import MessageFilePreviewService
from services.message_suggested_questions_adapters import MessageSuggestedQuestionsRuntime
from services.message_suggested_questions_service import MessageSuggestedQuestions
from services.message_suggested_questions_generator import SuggestedQuestionsGenerator
from services.message_suggested_questions_queries import SuggestedQuestionsQuery
from services.message_suggested_questions_service import MessageSuggestedQuestions, MessageSuggestedQuestionsService
from services.network_access_group_gateway import (
BillingNetworkAccessGroupEntitlementGateway,
NetworkAccessGroupGateway,
@@ -535,6 +537,11 @@ def build_application_services(
registration=account_services.lifecycle,
invitation_tokens=invitation_tokens,
)
suggested_questions = SuggestedQuestionsQuery(
session_factory=database_client,
repository_factory=MessageRepository,
)
return ApplicationServices(
rbac=rbac,
accounts=account_services,
@@ -656,7 +663,10 @@ def build_application_services(
files=MessageFilePreviewQueryRepository(session_factory=database_client),
storage=storage,
),
message_suggested_questions=MessageSuggestedQuestionsRuntime(session_factory=database_client),
message_suggested_questions=MessageSuggestedQuestionsService(
queries=suggested_questions,
generator=SuggestedQuestionsGenerator(),
),
plugin_file_uploads=PluginFileUploadService(
owners=SQLAlchemyPluginFileUploadOwnerRepository(session_factory=database_client),
files=ToolFilePluginUploadGateway(tool_files=ToolFileManager()),
+8
View File
@@ -27,6 +27,14 @@ class RequestContext(NamedTuple):
resource_app_ids: frozenset[str] | None = None
@dataclass(frozen=True, slots=True, kw_only=True)
class ServiceApiEndUserContext(AppRequestContext):
"""Validated application mode and end-user identity for a Service API request."""
app_mode: str
end_user_id: str
class AccountRequestContext(NamedTuple):
"""Stable identity for account-scoped use cases that do not require a workspace."""
+7 -1
View File
@@ -443,7 +443,13 @@ class Workflow(Base): # bug
@staticmethod
def _normalize_features_payload(features: dict[str, Any]) -> dict[str, Any]:
if features.get("file_upload", {}).get("image", {}).get("enabled", False):
file_upload = features.get("file_upload")
if not isinstance(file_upload, dict):
return features
image = file_upload.get("image")
if not isinstance(image, dict):
return features
if image.get("enabled", False):
image_number_limits = int(features["file_upload"]["image"].get("number_limits", DEFAULT_FILE_NUMBER_LIMITS))
image_transfer_methods = features["file_upload"]["image"].get(
"transfer_methods", ["remote_url", "local_file"]
+2 -1
View File
@@ -2979,7 +2979,7 @@ Get suggested questions for a message
| 400 | App or model provider unavailable, or generation failed | **application/json**: [AppUnavailableError](#appunavailableerror)<br>[CompletionRequestError](#completionrequesterror)<br>[ModelCurrentlyNotSupportError](#modelcurrentlynotsupporterror)<br>[ProviderNotInitializeError](#providernotinitializeerror)<br>[ProviderQuotaExceededError](#providerquotaexceedederror)<br>[ErrorResponse](#errorresponse)<br> |
| 401 | Account authentication required | **application/json**: [ErrorResponse](#errorresponse)<br> |
| 403 | Insufficient permissions or suggested questions disabled | **application/json**: [AppSuggestedQuestionsAfterAnswerDisabledError](#appsuggestedquestionsafteranswerdisablederror)<br>[ErrorResponse](#errorresponse)<br> |
| 404 | App, message, or conversation not found | **application/json**: [AppNotFoundError](#appnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
| 404 | App, message, or conversation not found | **application/json**: [AgentVersionNotFoundError](#agentversionnotfounderror)<br>[AppNotFoundError](#appnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
| 500 | Unexpected server error | **application/json**: [ErrorResponse](#errorresponse)<br> |
| default | Error | **application/json**: [ErrorResponse](#errorresponse)<br> |
@@ -13774,6 +13774,7 @@ Remove one or more tag bindings from a target.
| 200 | Success | **application/json**: [SuggestedQuestionsResponse](#suggestedquestionsresponse)<br> |
| 400 | Bad Request | **application/json**: [AppUnavailableError](#appunavailableerror)<br>[CompletionRequestError](#completionrequesterror)<br>[ModelCurrentlyNotSupportError](#modelcurrentlynotsupporterror)<br>[NotChatAppError](#notchatapperror)<br>[ProviderNotInitializeError](#providernotinitializeerror)<br>[ProviderQuotaExceededError](#providerquotaexceedederror)<br>[ErrorResponse](#errorresponse)<br> |
| 403 | Forbidden | **application/json**: [AppSuggestedQuestionsAfterAnswerDisabledError](#appsuggestedquestionsafteranswerdisablederror)<br>[TrialAppFeatureDisabledError](#trialappfeaturedisablederror)<br>[TrialAppLimitExceededError](#trialapplimitexceedederror)<br>[TrialAppNotAllowedError](#trialappnotallowederror)<br>[ErrorResponse](#errorresponse)<br> |
| 404 | Not Found | **application/json**: [AgentVersionNotFoundError](#agentversionnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
| default | Error | **application/json**: [ErrorResponse](#errorresponse)<br> |
### [GET] /trial-apps/{app_id}/parameters
+64 -8
View File
@@ -2024,7 +2024,7 @@ Stops a chat message generation task. Only supported in `streaming` mode.
### [GET] /messages/{message_id}/suggested
**Get Next Suggested Questions**
Get next questions suggestions for the current message.
Get next question suggestions for the current message. If no usable model can be resolved or the model call to generate questions fails, the response is HTTP 200 with an empty data list. Model invocation failures during history token counting instead return HTTP 400 with `completion_request_error`.
#### Parameters
@@ -2038,10 +2038,10 @@ Get next questions suggestions for the current message.
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Suggested questions retrieved successfully | **application/json**: [SimpleResultStringListResponse](#simpleresultstringlistresponse)<br> |
| 400 | - `not_chat_app` : App mode does not match the API route. - `bad_request` : Suggested questions feature is disabled. | **application/json**: [NotChatAppError](#notchatapperror)<br>[ErrorResponse](#errorresponse)<br> |
| 400 | - `not_chat_app` : App mode does not match the API route. - `app_unavailable` : App is no longer available. - `completion_request_error` : Model invocation failed while counting history tokens. | **application/json**: [AppUnavailableError](#appunavailableerror)<br>[CompletionRequestError](#completionrequesterror)<br>[ModelCurrentlyNotSupportError](#modelcurrentlynotsupporterror)<br>[NotChatAppError](#notchatapperror)<br>[ProviderNotInitializeError](#providernotinitializeerror)<br>[ProviderQuotaExceededError](#providerquotaexceedederror)<br>[ErrorResponse](#errorresponse)<br> |
| 401 | Unauthorized - invalid API token | **application/json**: [ErrorResponse](#errorresponse)<br> |
| 403 | Forbidden - token scope, app, dataset, or workspace access denied | **application/json**: [ErrorResponse](#errorresponse)<br> |
| 404 | `not_found` : Message does not exist. | **application/json**: [ErrorResponse](#errorresponse)<br> |
| 403 | - `forbidden` : Token scope does not allow access. - `app_not_found` : The token's app no longer exists. - `app_abnormal_status` : App status does not allow API access. - `app_api_disabled` : The app's API service has been disabled. - `workspace_not_found` : The app's workspace no longer exists. - `workspace_archived` : The app's workspace is archived. - `app_suggested_questions_after_answer_disabled` : Suggested questions feature is disabled. | **application/json**: [AppAbnormalStatusError](#appabnormalstatuserror)<br>[AppApiDisabledError](#appapidisablederror)<br>[AppNotFoundError](#appnotfounderror)<br>[AppSuggestedQuestionsAfterAnswerDisabledError](#appsuggestedquestionsafteranswerdisablederror)<br>[WorkspaceArchivedError](#workspacearchivederror)<br>[WorkspaceNotFoundError](#workspacenotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
| 404 | - `not_found` : End user, message, or conversation does not exist. - `agent_version_not_found_error` : Agent config version does not exist. | **application/json**: [AgentVersionNotFoundError](#agentversionnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
| 500 | `internal_server_error` : Internal server error. | **application/json**: [ErrorResponse](#errorresponse)<br> |
| default | Error | **application/json**: [ErrorResponse](#errorresponse)<br> |
@@ -2177,7 +2177,7 @@ Stops a chat message generation task. Only supported in `streaming` mode.
### [GET] /messages/{message_id}/suggested
**Get Next Suggested Questions**
Get next questions suggestions for the current message.
Get next question suggestions for the current message. If no usable model can be resolved or the model call to generate questions fails, the response is HTTP 200 with an empty data list. Model invocation failures during history token counting instead return HTTP 400 with `completion_request_error`.
#### Parameters
@@ -2191,10 +2191,10 @@ Get next questions suggestions for the current message.
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Suggested questions retrieved successfully | **application/json**: [SimpleResultStringListResponse](#simpleresultstringlistresponse)<br> |
| 400 | - `not_chat_app` : App mode does not match the API route. - `bad_request` : Suggested questions feature is disabled. | **application/json**: [NotChatAppError](#notchatapperror)<br>[ErrorResponse](#errorresponse)<br> |
| 400 | - `not_chat_app` : App mode does not match the API route. - `app_unavailable` : App is no longer available. - `completion_request_error` : Model invocation failed while counting history tokens. | **application/json**: [AppUnavailableError](#appunavailableerror)<br>[CompletionRequestError](#completionrequesterror)<br>[ModelCurrentlyNotSupportError](#modelcurrentlynotsupporterror)<br>[NotChatAppError](#notchatapperror)<br>[ProviderNotInitializeError](#providernotinitializeerror)<br>[ProviderQuotaExceededError](#providerquotaexceedederror)<br>[ErrorResponse](#errorresponse)<br> |
| 401 | Unauthorized - invalid API token | **application/json**: [ErrorResponse](#errorresponse)<br> |
| 403 | Forbidden - token scope, app, dataset, or workspace access denied | **application/json**: [ErrorResponse](#errorresponse)<br> |
| 404 | `not_found` : Message does not exist. | **application/json**: [ErrorResponse](#errorresponse)<br> |
| 403 | - `forbidden` : Token scope does not allow access. - `app_not_found` : The token's app no longer exists. - `app_abnormal_status` : App status does not allow API access. - `app_api_disabled` : The app's API service has been disabled. - `workspace_not_found` : The app's workspace no longer exists. - `workspace_archived` : The app's workspace is archived. - `app_suggested_questions_after_answer_disabled` : Suggested questions feature is disabled. | **application/json**: [AppAbnormalStatusError](#appabnormalstatuserror)<br>[AppApiDisabledError](#appapidisablederror)<br>[AppNotFoundError](#appnotfounderror)<br>[AppSuggestedQuestionsAfterAnswerDisabledError](#appsuggestedquestionsafteranswerdisablederror)<br>[WorkspaceArchivedError](#workspacearchivederror)<br>[WorkspaceNotFoundError](#workspacenotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
| 404 | - `not_found` : End user, message, or conversation does not exist. - `agent_version_not_found_error` : Agent config version does not exist. | **application/json**: [AgentVersionNotFoundError](#agentversionnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
| 500 | `internal_server_error` : Internal server error. | **application/json**: [ErrorResponse](#errorresponse)<br> |
| default | Error | **application/json**: [ErrorResponse](#errorresponse)<br> |
@@ -4068,6 +4068,14 @@ Agent has not been published. Please publish the Agent before using the API.
| tool_input | string | | No |
| tool_labels | [AnyJSONValue](#anyjsonvalue) | Labels for tools used. | Yes |
#### AgentVersionNotFoundError
Agent config version not found.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AgentVersionNotFoundError | | Agent config version not found. | |
#### Annotation
| Name | Type | Description | Required |
@@ -4132,6 +4140,22 @@ Agent has not been published. Please publish the Agent before using the API.
| ---- | ---- | ----------- | -------- |
| AnyJSONValue | | | |
#### AppAbnormalStatusError
The app's status is abnormal.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AppAbnormalStatusError | | The app's status is abnormal. | |
#### AppApiDisabledError
The app's API service has been disabled.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AppApiDisabledError | | The app's API service has been disabled. | |
#### AppFeedbackListResponse
| Name | Type | Description | Required |
@@ -4170,6 +4194,22 @@ Agent has not been published. Please publish the Agent before using the API.
| ---- | ---- | ----------- | -------- |
| tool_icons | object | | No |
#### AppNotFoundError
The app no longer exists.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AppNotFoundError | | The app no longer exists. | |
#### AppSuggestedQuestionsAfterAnswerDisabledError
The 'Suggested Questions After Answer' feature is disabled.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AppSuggestedQuestionsAfterAnswerDisabledError | | The 'Suggested Questions After Answer' feature is disabled. | |
#### AppUnavailableError
App unavailable, please check your app configurations.
@@ -8036,3 +8076,19 @@ Workflow version execution is not available on your current plan. Please upgrade
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| WorkflowVersionExecutionNotAllowedError | | Workflow version execution is not available on your current plan. Please upgrade to a paid plan. | |
#### WorkspaceArchivedError
The workspace's status is archived.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| WorkspaceArchivedError | | The workspace's status is archived. | |
#### WorkspaceNotFoundError
Tenant does not exist.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| WorkspaceNotFoundError | | Tenant does not exist. | |
+12 -4
View File
@@ -578,7 +578,7 @@ Generate a new completion similar to an existing message (completion apps only).
| default | Error | **application/json**: [ErrorResponse](#errorresponse)<br> |
### [GET] /messages/{message_id}/suggested-questions
Get suggested follow-up questions after a message (chat apps only).
Get suggested follow-up questions after a message (chat apps only). If no usable model can be resolved or the model call to generate questions fails, the response is HTTP 200 with an empty data list. Model invocation failures during history token counting instead return HTTP 400 with `completion_request_error`.
#### Parameters
@@ -591,10 +591,10 @@ Get suggested follow-up questions after a message (chat apps only).
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Success | **application/json**: [SuggestedQuestionsResponse](#suggestedquestionsresponse)<br> |
| 400 | Bad Request - Not a chat app or feature disabled | **application/json**: [CompletionRequestError](#completionrequesterror)<br>[ModelCurrentlyNotSupportError](#modelcurrentlynotsupporterror)<br>[NotChatAppError](#notchatapperror)<br>[ProviderNotInitializeError](#providernotinitializeerror)<br>[ProviderQuotaExceededError](#providerquotaexceedederror)<br>[ErrorResponse](#errorresponse)<br> |
| 400 | Bad Request - Not a chat app or app unavailable; `completion_request_error` when model invocation fails while counting history tokens. | **application/json**: [AppUnavailableError](#appunavailableerror)<br>[CompletionRequestError](#completionrequesterror)<br>[ModelCurrentlyNotSupportError](#modelcurrentlynotsupporterror)<br>[NotChatAppError](#notchatapperror)<br>[ProviderNotInitializeError](#providernotinitializeerror)<br>[ProviderQuotaExceededError](#providerquotaexceedederror)<br>[ErrorResponse](#errorresponse)<br> |
| 401 | Unauthorized | **application/json**: [ErrorResponse](#errorresponse)<br> |
| 403 | Forbidden | **application/json**: [AppSuggestedQuestionsAfterAnswerDisabledError](#appsuggestedquestionsafteranswerdisablederror)<br>[ErrorResponse](#errorresponse)<br> |
| 404 | Message Not Found or Conversation Not Found | **application/json**: [ErrorResponse](#errorresponse)<br> |
| 403 | Forbidden - Access denied or suggested questions disabled | **application/json**: [AppSuggestedQuestionsAfterAnswerDisabledError](#appsuggestedquestionsafteranswerdisablederror)<br>[ErrorResponse](#errorresponse)<br> |
| 404 | App, End User, Message, or Conversation Not Found | **application/json**: [AgentVersionNotFoundError](#agentversionnotfounderror)<br>[ErrorResponse](#errorresponse)<br> |
| 500 | Internal Server Error | **application/json**: [ErrorResponse](#errorresponse)<br> |
| default | Error | **application/json**: [ErrorResponse](#errorresponse)<br> |
@@ -1020,6 +1020,14 @@ Agent has not been published. Please publish the Agent before using the web app.
| tool_input | string | | No |
| tool_labels | [AnyJSONValue](#anyjsonvalue) | Labels for tools used. | Yes |
#### AgentVersionNotFoundError
Agent config version not found.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| AgentVersionNotFoundError | | Agent config version not found. | |
#### AnyJSONValue
| Name | Type | Description | Required |
@@ -2,7 +2,8 @@
from typing import Any, cast, override
from sqlalchemy import select
from sqlalchemy import String, select
from sqlalchemy import cast as sql_cast
from sqlalchemy.orm import Session, sessionmaker
from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot
@@ -21,6 +22,7 @@ from services.app_definition_query_service import (
AppParameterConfig,
AppSiteConfiguration,
AppToolIconSource,
ServiceApiAppRecord,
)
from services.web_app_runtime_query_service import WebAppRuntimeRecord
@@ -118,6 +120,31 @@ class AppDefinitionQueryRepository(AppDefinitionQuery):
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
self._session_factory = session_factory
@override
def get_service_api_record(self, app_id: str, *, tenant_id: str | None = None) -> ServiceApiAppRecord | None:
with self._session_factory() as session:
statement = (
# Let admission reject abnormal historical statuses with a domain
# error instead of failing enum deserialization before policy runs.
select(App.id, App.tenant_id, App.mode, sql_cast(App.status, String), App.enable_api, Tenant.status)
.outerjoin(Tenant, Tenant.id == App.tenant_id)
.where(App.id == app_id)
)
if tenant_id is not None:
statement = statement.where(App.tenant_id == tenant_id)
row = session.execute(statement).one_or_none()
if row is None:
return None
resolved_id, tenant_id, mode, status, enable_api, tenant_status = row
return ServiceApiAppRecord(
app_id=resolved_id,
tenant_id=tenant_id,
mode=mode.value,
status=status,
enable_api=enable_api,
tenant_status=tenant_status.value if tenant_status is not None else None,
)
@override
def get_mode(self, app_id: str) -> str | None:
with self._session_factory() as session:
+125
View File
@@ -0,0 +1,125 @@
"""Message ownership and history reads within the caller's bounded read session."""
from typing import NamedTuple
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.memory.token_buffer_memory import PreparedHistory, TokenBufferMemory
from models import Account, App, Conversation, EndUser, Message
from models.model import AppModelConfig
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.entities.message_entities import MessageAccount, MessageActor
from services.errors.conversation import ConversationNotExistsError
from services.errors.message import MessageActorNotFoundError, MessageNotExistsError
from services.message_suggested_questions_service import SuggestedQuestionsContext
class SuggestedQuestionsRecords(NamedTuple):
app: App
conversation: Conversation
class MessageRepository:
def __init__(self, session: Session) -> None:
self._session: Session = session
def get_context(
self,
*,
app_id: str,
app_owner_tenant_id: str,
expected_app_mode: str,
actor: MessageActor,
message_id: str,
) -> SuggestedQuestionsRecords:
app = self._session.scalar(select(App).where(App.id == app_id, App.tenant_id == app_owner_tenant_id))
if app is None or app.mode != expected_app_mode:
raise AppDefinitionUnavailableError(
f"App {app_id} is unavailable in tenant {app_owner_tenant_id} with mode {expected_app_mode}"
)
account_id = None
end_user_id = None
if isinstance(actor, MessageAccount):
account_id = actor.account_id
if self._session.get(Account, account_id) is None:
raise MessageActorNotFoundError(f"Account {account_id} no longer exists")
source = "console"
else:
end_user_id = actor.end_user_id
end_user = self._session.scalar(
select(EndUser).where(
EndUser.id == end_user_id, EndUser.app_id == app_id, EndUser.tenant_id == app_owner_tenant_id
)
)
if end_user is None:
raise MessageActorNotFoundError(
f"End user {end_user_id} does not exist for app {app_id} in tenant {app_owner_tenant_id}"
)
source = "api"
message = self._session.scalar(
select(Message).where(
Message.id == message_id,
Message.app_id == app_id,
Message.from_source == source,
Message.from_account_id == account_id,
Message.from_end_user_id == end_user_id,
)
)
if message is None:
raise MessageNotExistsError()
conversation = self._session.scalar(
select(Conversation).where(
Conversation.id == message.conversation_id,
Conversation.app_id == app_id,
Conversation.from_source == source,
Conversation.from_account_id == account_id,
Conversation.from_end_user_id == end_user_id,
Conversation.is_deleted.is_(False),
)
)
if conversation is None:
raise ConversationNotExistsError()
return SuggestedQuestionsRecords(app, conversation)
def get_model_config(self, *, app_id: str, config_id: str | None) -> AppModelConfig | None:
"""Return the app-owned configuration; a missing pointer or row returns None."""
if config_id is None:
return None
return self._session.scalar(
select(AppModelConfig).where(AppModelConfig.id == config_id, AppModelConfig.app_id == app_id)
)
def load_history(self, *, context: SuggestedQuestionsContext) -> PreparedHistory:
# Model resolution separates the two read phases. Reload only the app
# and conversation needed by the history reader, retaining their owner
# scope without repeating actor and target-message admission queries.
app = self._session.scalar(select(App).where(App.id == context.app_id, App.tenant_id == context.tenant_id))
if app is None or app.mode != context.app_mode:
raise AppDefinitionUnavailableError(
f"App {context.app_id} is unavailable in tenant {context.tenant_id} with mode {context.app_mode}"
)
actor = context.actor
account_id = actor.account_id if isinstance(actor, MessageAccount) else None
end_user_id = None if isinstance(actor, MessageAccount) else actor.end_user_id
conversation = self._session.scalar(
select(Conversation).where(
Conversation.id == context.conversation_id,
Conversation.app_id == context.app_id,
Conversation.from_source == ("console" if isinstance(actor, MessageAccount) else "api"),
Conversation.from_account_id == account_id,
Conversation.from_end_user_id == end_user_id,
Conversation.is_deleted.is_(False),
)
)
if conversation is None:
raise ConversationNotExistsError()
return TokenBufferMemory.load_history(
conversation=conversation,
app_record=app,
session=self._session,
message_limit=3,
)
@@ -6,6 +6,8 @@ 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
from services.errors.app import AppAbnormalStatusError, AppApiDisabledError
from services.errors.workspace import WorkspaceArchivedError, WorkspaceNotFoundError
class AppParameterConfig(NamedTuple):
@@ -28,6 +30,17 @@ class AppDefinitionSummary(NamedTuple):
author_name: str | None
class ServiceApiAppRecord(NamedTuple):
"""App admission state; a null tenant status means the workspace is missing."""
app_id: str
tenant_id: str
mode: str
status: str
enable_api: bool
tenant_status: str | None
class AppSiteConfiguration(NamedTuple):
title: str
chat_color_theme: str | None
@@ -49,6 +62,8 @@ class AppSiteConfiguration(NamedTuple):
class AppDefinitionQuery(Protocol):
def get_mode(self, app_id: str) -> str | None: ...
def get_service_api_record(self, app_id: str, *, tenant_id: str | None = None) -> ServiceApiAppRecord | None: ...
def get_published_parameter_config(
self,
app_id: str,
@@ -84,6 +99,29 @@ class AppDefinitionQueryService:
self._definitions = definitions
self._builtin_icon_url_prefix = builtin_icon_url_prefix
def get_service_api_app(self, app_id: str | None, *, tenant_id: str | None = None) -> ServiceApiAppRecord:
"""Return an app admitted for Service API access, or raise a domain error.
``None`` means the API token no longer references an app. The repository
closes its read session before this policy runs or an end user is provisioned.
Resource-token grants provide ``tenant_id`` to constrain the lookup to their
workspace; ``None`` retains legacy app tokens' app-ID-only lookup.
"""
if app_id is None:
raise AppDefinitionUnavailableError("The app no longer exists.")
app = self._definitions.get_service_api_record(app_id, tenant_id=tenant_id)
if app is None:
raise AppDefinitionUnavailableError("The app no longer exists.")
if app.status != "normal":
raise AppAbnormalStatusError("The app's status is abnormal.")
if not app.enable_api:
raise AppApiDisabledError("The app's API service has been disabled.")
if app.tenant_status is None:
raise WorkspaceNotFoundError("Tenant does not exist.")
if app.tenant_status == "archive":
raise WorkspaceArchivedError("The workspace's status is archived.")
return app
def get_mode(self, app_id: str) -> str:
mode = self._definitions.get_mode(app_id)
if mode is None:
+16
View File
@@ -0,0 +1,16 @@
"""Caller identities shared by message operations, independent of invocation origin."""
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class MessageAccount:
account_id: str
@dataclass(frozen=True, slots=True)
class MessageEndUser:
end_user_id: str
type MessageActor = MessageAccount | MessageEndUser
+8
View File
@@ -14,6 +14,14 @@ class MoreLikeThisDisabledError(Exception):
pass
class AppAbnormalStatusError(Exception):
"""The app is not in its normal operating state."""
class AppApiDisabledError(Exception):
"""The app owner has disabled Service API access."""
class WorkflowHashNotEqualError(Exception):
pass
+4
View File
@@ -1,6 +1,10 @@
from services.errors.base import BaseServiceError
class MessageActorNotFoundError(LookupError):
"""The account is missing, or the end user is outside the admitted app and tenant scope."""
class FirstMessageNotExistsError(BaseServiceError):
pass
+1 -226
View File
@@ -1,38 +1,18 @@
import logging
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from datetime import datetime
from typing import cast
from collections.abc import Sequence
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from core.app.apps.advanced_chat.app_config_manager import AdvancedChatAppConfigManager
from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features
from core.app.entities.app_invoke_entities import InvokeFrom, get_credit_usage_app_type
from core.llm_generator.llm_generator import LLMGenerator
from core.memory.token_buffer_memory import TokenBufferMemory
from core.model_context import use_credit_usage_metadata
from core.model_manager import ModelInstance, ModelManager
from core.ops.entities.trace_entity import TraceTaskName
from core.ops.ops_trace_manager import TraceQueueManager, TraceTask
from core.ops.utils import measure_time
from extensions.ext_database import db
from graphon.model_runtime.entities.model_entities import ModelType
from libs.infinite_scroll_pagination import InfiniteScrollPagination
from models import Account
from models.agent_config_entities import AgentSoulConfig
from models.enums import FeedbackFromSource, FeedbackRating
from models.model import (
App,
AppMode,
AppModelConfig,
Conversation,
EndUser,
Message,
MessageFeedback,
SuggestedQuestionsAfterAnswerConfig,
load_annotation_reply_config,
)
from repositories.execution_extra_content_repository import ExecutionExtraContentRepository
from repositories.sqlalchemy_execution_extra_content_repository import (
@@ -43,9 +23,7 @@ from services.errors.message import (
FirstMessageNotExistsError,
LastMessageNotExistsError,
MessageNotExistsError,
SuggestedQuestionsAfterAnswerDisabledError,
)
from services.workflow_service import WorkflowService
logger = logging.getLogger(__name__)
@@ -67,56 +45,7 @@ def attach_message_extra_contents(messages: Sequence[Message]) -> None:
message.set_extra_contents([content.model_dump(mode="json", exclude_none=True) for content in contents])
@dataclass(frozen=True, slots=True)
class SuggestedQuestionsContext:
"""Validated configuration and loaded conversation for the legacy history reader.
The conversation's scalar fields remain usable after the preparation session
closes. History loading must use an explicit session, never ORM properties.
"""
app_id: str
tenant_id: str
app_mode: str
message_id: str
conversation: Conversation
instruction_prompt: str | None
model_config: object | None
class MessageService:
@classmethod
def _get_agent_suggested_questions_config(
cls,
*,
app_model: App,
user: Account | EndUser,
conversation: Conversation,
invoke_from: InvokeFrom,
session: Session,
) -> SuggestedQuestionsAfterAnswerConfig:
from services.agent.runtime_config_service import AgentRuntimeConfigService
agent_soul = AgentRuntimeConfigService(session).resolve_conversation_soul(
app_model=app_model,
conversation=conversation,
account_id=user.id if isinstance(user, Account) else None,
use_debug_draft=invoke_from == InvokeFrom.DEBUGGER,
)
app_model_config = (
session.get(AppModelConfig, app_model.app_model_config_id) if app_model.app_model_config_id else None
)
annotation_reply = load_annotation_reply_config(session, app_model.id) if app_model_config else None
features = merge_agent_app_features(
agent_soul=agent_soul or AgentSoulConfig(),
app_model_config=app_model_config,
annotation_reply=annotation_reply,
)
suggested_questions = features.get("suggested_questions_after_answer")
if not isinstance(suggested_questions, dict) or not suggested_questions.get("enabled", False):
raise SuggestedQuestionsAfterAnswerDisabledError()
return cast(SuggestedQuestionsAfterAnswerConfig, suggested_questions)
@classmethod
def pagination_by_first_id(
cls,
@@ -351,157 +280,3 @@ class MessageService:
raise MessageNotExistsError()
return message
@classmethod
def prepare_suggested_questions_after_answer(
cls,
app_model: App,
user: Account | EndUser | None,
message_id: str,
invoke_from: InvokeFrom,
*,
session: Session,
) -> SuggestedQuestionsContext | None:
"""Read authorization and feature configuration; None means no published workflow."""
if not user:
raise ValueError("user cannot be None")
message = cls.get_message(app_model=app_model, user=user, message_id=message_id, session=session)
conversation = ConversationService.get_conversation(
app_model=app_model, conversation_id=message.conversation_id, user=user, session=session
)
suggested_questions_after_answer_config: SuggestedQuestionsAfterAnswerConfig = {"enabled": False}
if app_model.mode == AppMode.ADVANCED_CHAT:
workflow_service = WorkflowService()
if invoke_from == InvokeFrom.DEBUGGER:
workflow = workflow_service.get_draft_workflow(app_model=app_model, session=session)
else:
workflow = workflow_service.get_published_workflow(app_model=app_model, session=session)
if workflow is None:
return None
app_config = AdvancedChatAppConfigManager.get_app_config(app_model=app_model, workflow=workflow)
if not app_config.additional_features:
raise ValueError("Additional features not found")
if not app_config.additional_features.suggested_questions_after_answer:
raise SuggestedQuestionsAfterAnswerDisabledError()
suggested_questions_after_answer = workflow.features_dict.get("suggested_questions_after_answer")
if isinstance(suggested_questions_after_answer, dict):
suggested_questions_after_answer_config = cast(
SuggestedQuestionsAfterAnswerConfig, suggested_questions_after_answer
)
elif app_model.mode == AppMode.AGENT:
suggested_questions_after_answer_config = cls._get_agent_suggested_questions_config(
app_model=app_model,
user=user,
conversation=conversation,
invoke_from=invoke_from,
session=session,
)
else:
if not conversation.override_model_configs:
app_model_config = session.scalar(
select(AppModelConfig)
.where(AppModelConfig.id == conversation.app_model_config_id, AppModelConfig.app_id == app_model.id)
.limit(1)
)
else:
app_model_config = AppModelConfig(
app_id=app_model.id,
)
# Reuse Conversation.model_config so suggested-questions reads the same
# compatibility-normalized config as the rest of the message flow.
app_model_config = app_model_config.from_model_config_dict(
conversation.model_config_with_session(session=session)
)
if not app_model_config:
raise ValueError("did not find app model config")
suggested_questions_after_answer_config = app_model_config.suggested_questions_after_answer_dict
if suggested_questions_after_answer_config.get("enabled", False) is False:
raise SuggestedQuestionsAfterAnswerDisabledError()
instruction_prompt = suggested_questions_after_answer_config.get("prompt")
if not isinstance(instruction_prompt, str) or not instruction_prompt.strip():
instruction_prompt = None
return SuggestedQuestionsContext(
app_id=app_model.id,
tenant_id=app_model.tenant_id,
app_mode=app_model.mode,
message_id=message_id,
conversation=conversation,
instruction_prompt=instruction_prompt,
model_config=suggested_questions_after_answer_config.get("model"),
)
@staticmethod
def get_suggested_questions_history_model(*, tenant_id: str) -> ModelInstance | None:
"""Resolve the history token counter; None preserves the no-model fallback."""
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
try:
return model_manager.get_default_model_instance(tenant_id=tenant_id, model_type=ModelType.LLM)
except Exception:
logger.exception("Failed to resolve the history model for suggested questions")
return None
@classmethod
def get_suggested_questions_after_answer(
cls,
app_model: App,
user: Account | EndUser | None,
message_id: str,
invoke_from: InvokeFrom,
*,
session: Session,
) -> list[str]:
"""Compatibility entry point; the caller still owns its request session.
Admitted callers use MessageSuggestedQuestionsRuntime to release query
and model-resolution sessions before attachment and provider I/O.
"""
context = cls.prepare_suggested_questions_after_answer(
app_model=app_model, user=user, message_id=message_id, invoke_from=invoke_from, session=session
)
if context is None:
return []
model_instance = cls.get_suggested_questions_history_model(tenant_id=context.tenant_id)
if model_instance is None:
return []
memory = TokenBufferMemory(conversation=context.conversation, model_instance=model_instance)
histories = memory.get_history_prompt_text(max_token_limit=3000, message_limit=3)
with (
measure_time() as timer,
use_credit_usage_metadata({"app_type": get_credit_usage_app_type(context.app_mode)}),
):
questions = list(
LLMGenerator.generate_suggested_questions_after_answer(
tenant_id=context.tenant_id,
histories=histories,
instruction_prompt=context.instruction_prompt,
model_config=context.model_config,
)
)
cls.trace_suggested_questions(context=context, questions=questions, timer=timer)
return questions
@staticmethod
def trace_suggested_questions(
*, context: SuggestedQuestionsContext, questions: list[str], timer: Mapping[str, datetime | None]
) -> None:
trace_manager = TraceQueueManager(app_id=context.app_id)
trace_manager.add_trace_task(
TraceTask(
TraceTaskName.SUGGESTED_QUESTION_TRACE,
message_id=context.message_id,
suggested_question=questions,
timer=timer,
)
)
@@ -1,129 +0,0 @@
"""Run suggested questions with bounded query and model-resolution sessions.
Keep legacy configuration, history and provider policy with their existing
owners. This bridge controls their lifetimes until those owners accept only
framework-neutral inputs; it never closes the caller's scoped session.
"""
from typing import override
from flask import current_app
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from core.app.entities.app_invoke_entities import InvokeFrom, get_credit_usage_app_type
from core.llm_generator.llm_generator import LLMGenerator
from core.memory.token_buffer_memory import TokenBufferMemory
from core.model_context import use_credit_usage_metadata
from core.ops.utils import measure_time
from extensions.ext_database import db
from models import Account, App
from models.model import EndUser
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.message_service import MessageService
from services.message_suggested_questions_service import (
MessageSuggestedQuestions,
SuggestedQuestionsAccount,
SuggestedQuestionsActor,
SuggestedQuestionsActorNotFoundError,
)
class MessageSuggestedQuestionsRuntime(MessageSuggestedQuestions):
def __init__(self, *, session_factory: sessionmaker[Session]) -> None:
self._session_factory: sessionmaker[Session] = session_factory
@override
def get_suggested_questions(
self,
*,
app_id: str,
app_owner_tenant_id: str,
expected_app_mode: str,
actor: SuggestedQuestionsActor,
message_id: str,
) -> list[str]:
with self._session_factory(expire_on_commit=False) as session:
app_model = session.get(App, app_id)
if app_model is None or app_model.tenant_id != app_owner_tenant_id:
raise AppDefinitionUnavailableError(f"App {app_id} no longer exists in tenant {app_owner_tenant_id}")
if app_model.mode != expected_app_mode:
raise AppDefinitionUnavailableError(
f"App {app_id} mode changed from {expected_app_mode} to {app_model.mode} after admission"
)
user: Account | EndUser | None
if isinstance(actor, SuggestedQuestionsAccount):
user = session.get(Account, actor.account_id)
if user is None:
raise SuggestedQuestionsActorNotFoundError(f"Account {actor.account_id} no longer exists")
else:
user = session.scalar(
select(EndUser).where(
EndUser.id == actor.end_user_id,
EndUser.app_id == app_id,
EndUser.tenant_id == app_owner_tenant_id,
)
)
if user is None:
raise SuggestedQuestionsActorNotFoundError(
f"End user {actor.end_user_id} does not exist for app {app_id} in tenant {app_owner_tenant_id}"
)
context = MessageService.prepare_suggested_questions_after_answer(
app_model=app_model,
user=user,
message_id=message_id,
invoke_from=InvokeFrom(actor.invoke_from),
session=session,
)
if context is None:
return []
# Legacy model resolution, quota and tracing use db.session and may
# commit. Own one isolated scope so none can commit or close the caller's
# session. Release its sessions explicitly between phases; teardown also
# handles early returns and exceptions.
with current_app.app_context():
history_model = MessageService.get_suggested_questions_history_model(tenant_id=context.tenant_id)
db.session.remove()
if history_model is None:
return []
# Resolve the history model first to preserve the no-model fallback
# even when old history contains invalid file configuration.
with self._session_factory(expire_on_commit=False) as session:
history = TokenBufferMemory.load_history(
conversation=context.conversation,
app_record=app_model,
session=session,
message_limit=3,
)
histories = history.get_prompt_text(model_instance=history_model, max_token_limit=3000)
with (
measure_time() as timer,
use_credit_usage_metadata({"app_type": get_credit_usage_app_type(context.app_mode)}),
):
model = LLMGenerator.prepare_suggested_questions_model(
tenant_id=context.tenant_id, model_config=context.model_config
)
db.session.remove()
questions = (
list(
LLMGenerator.invoke_suggested_questions_after_answer(
prepared_model=model,
histories=histories,
instruction_prompt=context.instruction_prompt,
)
)
if model is not None
else []
)
# Generation can return [] after catching an error. Tracing must not
# inherit an open or failed transaction from that invocation.
db.session.remove()
MessageService.trace_suggested_questions(context=context, questions=questions, timer=timer)
return questions
@@ -0,0 +1,257 @@
"""Generate suggested questions after all conversation query sessions close."""
import logging
from collections.abc import Callable, Generator, Sequence
from contextlib import contextmanager
from dataclasses import dataclass
from functools import partial
from typing import TYPE_CHECKING, cast
from flask import current_app
from core.app.entities.app_invoke_entities import CreditUsageCreatedBy, get_credit_usage_app_type
from core.llm_generator.llm_generator import _normalize_completion_params
from core.llm_generator.output_parser.suggested_questions_after_answer import SuggestedQuestionsAfterAnswerOutputParser
from core.model_context import use_credit_usage_metadata, with_credit_usage_created_by
from core.model_manager import ModelInstance, ModelManager
from core.ops.entities.trace_entity import TraceTaskName
from core.ops.ops_trace_manager import TraceQueueManager, TraceTask
from core.ops.utils import measure_time
from core.plugin.impl.base import use_plugin_daemon_request_timeout
from core.prompt.utils.prompt_template_parser import PromptTemplateParser
from extensions.ext_database import db
from graphon.model_runtime.entities.llm_entities import LLMResult
from graphon.model_runtime.entities.message_entities import PromptMessage, UserPromptMessage
from graphon.model_runtime.entities.model_entities import ModelType, ParameterType
if TYPE_CHECKING:
from core.memory.token_buffer_memory import PreparedHistory
from services.message_suggested_questions_service import SuggestedQuestionsContext
logger = logging.getLogger(__name__)
_SUGGESTED_QUESTIONS_MAX_TOKENS = 256
_SUGGESTED_QUESTIONS_TIMEOUT_SECONDS = 30.0
_LOW_REASONING_EFFORTS = ("none", "minimal", "low")
@dataclass(frozen=True)
class PreparedSuggestedQuestionsModel:
"""Resolved model credentials; ``None`` parameters select the default-model tuning."""
model_instance: ModelInstance
completion_params: dict[str, object] | None
def _default_suggested_questions_model_parameters(model_instance: ModelInstance) -> dict[str, object]:
"""Build a low-latency parameter set for the workspace default model."""
parameters: dict[str, object] = {
"max_tokens": _SUGGESTED_QUESTIONS_MAX_TOKENS,
"temperature": 0.0,
}
try:
model_schema = model_instance.get_model_schema()
except Exception:
logger.warning("Failed to inspect the default model schema for suggested questions", exc_info=True)
return parameters
parameter_rules = {rule.name: rule for rule in model_schema.parameter_rules}
thinking_rule = parameter_rules.get("thinking")
if thinking_rule is not None:
if thinking_rule.type == ParameterType.BOOLEAN:
parameters["thinking"] = False
return parameters
if "disabled" in thinking_rule.options:
parameters["thinking"] = "disabled"
return parameters
reasoning_effort_rule = parameter_rules.get("reasoning_effort")
if reasoning_effort_rule is not None:
for effort in _LOW_REASONING_EFFORTS:
if effort in reasoning_effort_rule.options:
parameters["reasoning_effort"] = effort
break
return parameters
class SuggestedQuestionsGenerator:
"""Own model resolution, detached-history rendering, generation and tracing."""
@contextmanager
def prepare(
self,
*,
context: "SuggestedQuestionsContext",
instruction_prompt: str | None,
model_config: object | None,
) -> Generator[Callable[["PreparedHistory"], list[str]] | None]:
"""Yield a request-local generator, or None if its history model is unavailable."""
# Model resolution and tracing may use or commit the scoped session.
# Isolate them from the caller, retaining one app context across phases.
with current_app.app_context():
history_model = self._get_history_model(tenant_id=context.tenant_id)
db.session.remove()
if history_model is None:
yield None
else:
yield partial(
self.generate,
history_model=history_model,
context=context,
instruction_prompt=instruction_prompt,
model_config=model_config,
)
def generate(
self,
history: "PreparedHistory",
*,
history_model: ModelInstance,
context: "SuggestedQuestionsContext",
instruction_prompt: str | None,
model_config: object | None,
) -> list[str]:
"""Consume detached history within the isolation scope opened by prepare()."""
histories = history.get_prompt_text(model_instance=history_model, max_token_limit=3000)
with (
measure_time() as timer,
use_credit_usage_metadata({"app_type": get_credit_usage_app_type(context.app_mode)}),
):
model = self._prepare_model(tenant_id=context.tenant_id, model_config=model_config)
db.session.remove()
questions = (
list(self._invoke(prepared_model=model, histories=histories, instruction_prompt=instruction_prompt))
if model is not None
else []
)
# Invocation catches provider failures, including failed database
# transactions. Tracing always starts with a fresh scoped session.
db.session.remove()
TraceQueueManager(app_id=context.app_id).add_trace_task(
TraceTask(
TraceTaskName.SUGGESTED_QUESTION_TRACE,
message_id=context.message_id,
suggested_question=questions,
timer=timer,
)
)
return questions
@staticmethod
def _get_history_model(*, tenant_id: str) -> ModelInstance | None:
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
try:
return model_manager.get_default_model_instance(tenant_id=tenant_id, model_type=ModelType.LLM)
except Exception:
logger.exception("Failed to resolve the history model for suggested questions")
return None
@classmethod
@with_credit_usage_created_by(CreditUsageCreatedBy.SUGGESTED_QUESTIONS)
def _prepare_model(
cls,
tenant_id: str,
*,
model_config: object | None = None,
) -> PreparedSuggestedQuestionsModel | None:
"""Resolve the model before its caller releases database sessions.
``None`` means no usable configured or default model was found. Schema
inspection and generation belong to invocation, after the read phase.
"""
try:
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
configured_model = cast(dict[str, object], model_config) if isinstance(model_config, dict) else {}
provider = configured_model.get("provider")
model_name = configured_model.get("name")
use_configured_model = False
if isinstance(provider, str) and provider and isinstance(model_name, str) and model_name:
try:
model_instance = model_manager.get_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
provider=provider,
model=model_name,
)
use_configured_model = True
except Exception:
logger.warning(
"Failed to use configured suggested-questions model %s/%s, fallback to default model",
provider,
model_name,
exc_info=True,
)
model_instance = model_manager.get_default_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
)
else:
model_instance = model_manager.get_default_model_instance(
tenant_id=tenant_id,
model_type=ModelType.LLM,
)
except Exception:
logger.exception("Failed to resolve the suggested-questions model")
return None
completion_params: dict[str, object] | None = None
if use_configured_model:
configured_completion_params = configured_model.get("completion_params")
completion_params = (
dict(configured_completion_params) if isinstance(configured_completion_params, dict) else {}
)
return PreparedSuggestedQuestionsModel(
model_instance=model_instance,
completion_params=completion_params,
)
@classmethod
@with_credit_usage_created_by(CreditUsageCreatedBy.SUGGESTED_QUESTIONS)
def _invoke(
cls,
prepared_model: PreparedSuggestedQuestionsModel,
histories: str,
*,
instruction_prompt: str | None = None,
) -> Sequence[str]:
"""Generate with an already resolved model without repeating model resolution."""
output_parser = SuggestedQuestionsAfterAnswerOutputParser(instruction_prompt=instruction_prompt)
format_instructions = output_parser.get_format_instructions()
prompt_template = PromptTemplateParser(template="{{histories}}\n{{format_instructions}}\nquestions:\n")
prompt = prompt_template.format({"histories": histories, "format_instructions": format_instructions})
prompt_messages: list[PromptMessage] = [UserPromptMessage(content=prompt)]
questions: Sequence[str] = []
try:
model_parameters: dict[str, object]
stop: list[str]
model_instance = prepared_model.model_instance
if prepared_model.completion_params is not None:
model_parameters, stop = _normalize_completion_params(prepared_model.completion_params)
else:
# Default-model generation keeps the built-in suggested-questions tuning.
model_parameters = _default_suggested_questions_model_parameters(model_instance)
stop = []
with use_plugin_daemon_request_timeout(_SUGGESTED_QUESTIONS_TIMEOUT_SECONDS):
response: LLMResult = model_instance.invoke_llm(
prompt_messages=list(prompt_messages),
model_parameters=model_parameters,
stop=stop,
stream=False,
)
text_content = response.message.get_text_content()
questions = output_parser.parse(text_content) if text_content else []
except Exception:
logger.exception("Failed to generate suggested questions after answer")
questions = []
return questions
@@ -0,0 +1,144 @@
"""Select message configuration using the existing Agent and Workflow policy owners.
Each operation owns a short read session. Only scalar configuration or detached
history leaves this module; model resolution and attachment I/O happen afterwards.
"""
from collections.abc import Callable, Mapping
from copy import deepcopy
from types import MappingProxyType
from sqlalchemy.orm import Session, sessionmaker
from core.app.app_config.features.suggested_questions_after_answer.manager import (
SuggestedQuestionsAfterAnswerConfigManager,
)
from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features
from core.memory.token_buffer_memory import PreparedHistory
from models import AppMode
from models.agent_config_entities import AgentSoulConfig
from models.model import AppModelConfig, load_annotation_reply_config
from repositories.message_repository import MessageRepository, SuggestedQuestionsRecords
from services.agent.runtime_config_service import AgentRuntimeConfigService
from services.entities.message_entities import MessageAccount, MessageActor
from services.message_suggested_questions_service import (
SuggestedQuestionsContext,
SuggestedQuestionsInvokeFrom,
)
from services.workflow_service import WorkflowService
class SuggestedQuestionsQuery:
"""Coordinate repository reads with the existing ORM-based config readers.
This query owns each short session because AgentRuntimeConfigService,
WorkflowService and conversation config helpers still need a caller session.
The composition root supplies repository_factory to select the repository;
it borrows that same session rather than opening another one.
TODO: Move session ownership into repositories once those config readers
expose detached results, keeping their configuration policies in services.
"""
def __init__(
self,
*,
session_factory: sessionmaker[Session],
repository_factory: Callable[[Session], MessageRepository],
) -> None:
self._session_factory: sessionmaker[Session] = session_factory
self._repository_factory: Callable[[Session], MessageRepository] = repository_factory
def prepare(
self,
*,
app_id: str,
app_owner_tenant_id: str,
expected_app_mode: str,
actor: MessageActor,
invoke_from: SuggestedQuestionsInvokeFrom,
message_id: str,
) -> SuggestedQuestionsContext | None:
with self._session_factory(expire_on_commit=False) as session:
repository = self._repository_factory(session)
records = repository.get_context(
app_id=app_id,
app_owner_tenant_id=app_owner_tenant_id,
expected_app_mode=expected_app_mode,
actor=actor,
message_id=message_id,
)
config = self._configuration(
session=session, repository=repository, records=records, actor=actor, invoke_from=invoke_from
)
if config is None:
return None
return SuggestedQuestionsContext(
app_id=app_id,
tenant_id=app_owner_tenant_id,
app_mode=records.app.mode,
message_id=message_id,
conversation_id=records.conversation.id,
actor=actor,
invoke_from=invoke_from,
config=MappingProxyType(deepcopy(dict(config))),
)
@staticmethod
def _configuration(
*,
session: Session,
repository: MessageRepository,
records: SuggestedQuestionsRecords,
actor: MessageActor,
invoke_from: SuggestedQuestionsInvokeFrom,
) -> Mapping[str, object] | None:
app, conversation = records
if app.mode == AppMode.ADVANCED_CHAT:
workflows = WorkflowService()
workflow = (
workflows.get_draft_workflow(app_model=app, session=session)
if invoke_from == "debugger"
else workflows.get_published_workflow(app_model=app, session=session)
)
if workflow is None:
return None
# features_dict mutates the workflow during compatibility normalization.
# This is a query: use the same normalization without rewriting stored JSON.
features = workflow.normalized_features_dict
config = features.get("suggested_questions_after_answer")
return {
**(config if isinstance(config, dict) else {}),
"enabled": SuggestedQuestionsAfterAnswerConfigManager.convert(features),
}
if app.mode == AppMode.AGENT:
soul = AgentRuntimeConfigService(session).resolve_conversation_soul(
app_model=app,
conversation=conversation,
account_id=actor.account_id if isinstance(actor, MessageAccount) else None,
use_debug_draft=invoke_from == "debugger",
)
model_config = repository.get_model_config(app_id=app.id, config_id=app.app_model_config_id)
annotation_reply = load_annotation_reply_config(session, app.id) if model_config else None
features = merge_agent_app_features(
agent_soul=soul or AgentSoulConfig(),
app_model_config=model_config,
annotation_reply=annotation_reply,
)
config = features.get("suggested_questions_after_answer")
return config if isinstance(config, dict) else {}
if conversation.override_model_configs:
model_config = AppModelConfig(app_id=app.id).from_model_config_dict(
conversation.model_config_with_session(session=session)
)
else:
model_config = repository.get_model_config(app_id=app.id, config_id=conversation.app_model_config_id)
if model_config is None:
raise ValueError("did not find app model config")
return model_config.suggested_questions_after_answer_dict
def load_history(self, context: SuggestedQuestionsContext) -> PreparedHistory:
with self._session_factory(expire_on_commit=False) as session:
return self._repository_factory(session).load_history(context=context)
@@ -4,27 +4,15 @@ Callers own access admission, supported app modes, quotas and HTTP errors.
The app owner tenant is independent of an account's active workspace.
"""
from collections.abc import Callable, Mapping
from contextlib import AbstractContextManager
from dataclasses import dataclass
from typing import Literal, Protocol
from services.entities.message_entities import MessageActor
from services.errors.message import SuggestedQuestionsAfterAnswerDisabledError
@dataclass(frozen=True, slots=True)
class SuggestedQuestionsAccount:
account_id: str
invoke_from: Literal["explore", "debugger"]
@dataclass(frozen=True, slots=True)
class SuggestedQuestionsEndUser:
end_user_id: str
invoke_from: Literal["web-app", "service-api"]
type SuggestedQuestionsActor = SuggestedQuestionsAccount | SuggestedQuestionsEndUser
class SuggestedQuestionsActorNotFoundError(LookupError):
"""The account is missing, or the end user is outside the admitted app scope."""
type SuggestedQuestionsInvokeFrom = Literal["explore", "debugger", "web-app", "service-api"]
class MessageSuggestedQuestions(Protocol):
@@ -34,6 +22,110 @@ class MessageSuggestedQuestions(Protocol):
app_id: str,
app_owner_tenant_id: str,
expected_app_mode: str,
actor: SuggestedQuestionsActor,
actor: MessageActor,
invoke_from: SuggestedQuestionsInvokeFrom,
message_id: str,
) -> list[str]: ...
@dataclass(frozen=True, slots=True)
class SuggestedQuestionsContext:
"""An authorized message and its selected configuration, without ORM state."""
app_id: str
tenant_id: str
app_mode: str
message_id: str
conversation_id: str
actor: MessageActor
invoke_from: SuggestedQuestionsInvokeFrom
config: Mapping[str, object]
class SuggestedQuestionsContextQuery[HistoryT](Protocol):
def prepare(
self,
*,
app_id: str,
app_owner_tenant_id: str,
expected_app_mode: str,
actor: MessageActor,
invoke_from: SuggestedQuestionsInvokeFrom,
message_id: str,
) -> SuggestedQuestionsContext | None:
"""Return message configuration, or None when there is no selected workflow."""
...
def load_history(self, *, context: SuggestedQuestionsContext) -> HistoryT:
"""Return detached history after closing its query session."""
...
class SuggestedQuestionsGeneration[HistoryT](Protocol):
def prepare(
self,
*,
context: SuggestedQuestionsContext,
instruction_prompt: str | None,
model_config: object | None,
) -> AbstractContextManager[Callable[[HistoryT], list[str]] | None]:
"""Isolate one generation attempt and resolve its history model.
Yield None when no history model is available; otherwise yield a
generator accepting detached history. None prompt/model inputs select
the built-in prompt and default model.
"""
...
class MessageSuggestedQuestionsService[HistoryT]:
"""Apply the feature policy shared by every message suggested-question endpoint."""
def __init__(
self,
*,
queries: SuggestedQuestionsContextQuery[HistoryT],
generator: SuggestedQuestionsGeneration[HistoryT],
) -> None:
self._queries: SuggestedQuestionsContextQuery[HistoryT] = queries
self._generator: SuggestedQuestionsGeneration[HistoryT] = generator
def get_suggested_questions(
self,
*,
app_id: str,
app_owner_tenant_id: str,
expected_app_mode: str,
actor: MessageActor,
invoke_from: SuggestedQuestionsInvokeFrom,
message_id: str,
) -> list[str]:
context = self._queries.prepare(
app_id=app_id,
app_owner_tenant_id=app_owner_tenant_id,
expected_app_mode=expected_app_mode,
actor=actor,
invoke_from=invoke_from,
message_id=message_id,
)
if context is None:
return []
enabled = context.config.get("enabled", False)
# Agent and workflow features use truthiness; legacy model configs have
# historically disabled this feature only for an explicit False value.
disabled = not enabled if context.app_mode in {"agent", "advanced-chat"} else enabled is False
if disabled:
raise SuggestedQuestionsAfterAnswerDisabledError()
prompt = context.config.get("prompt")
instruction_prompt = prompt if isinstance(prompt, str) and prompt.strip() else None
with self._generator.prepare(
context=context,
instruction_prompt=instruction_prompt,
model_config=context.config.get("model"),
) as generate:
if generate is None:
return []
history = self._queries.load_history(context=context)
return generate(history)
@@ -12,12 +12,10 @@ from sqlalchemy.orm import Session
from controllers.console.app.message import AppMessageFeedbackPayload, ChatMessagesQuery
from controllers.console.app.message import attach_message_extra_contents as _attach_message_extra_contents
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from libs.datetime_utils import naive_utc_now
from models.enums import ConversationFromSource, ConversationStatus, FeedbackRating
from models.model import AppMode, Conversation, Message, MessageAnnotation, MessageFeedback
from services.errors.conversation import ConversationNotExistsError
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
from models.model import AppMode, AppModelConfig, Conversation, Message, MessageAnnotation, MessageFeedback
from models.provider import Provider
from tests.e2e_tests.containers.controllers.console.helpers import (
authenticate_console_client,
create_console_account_and_tenant,
@@ -236,63 +234,65 @@ def test_message_annotation_count(
assert response.get_json() == {"count": 1}
def test_message_suggested_questions_success(
db_session_with_containers: Session,
test_client_with_containers: FlaskClient,
) -> None:
account, tenant = create_console_account_and_tenant(db_session_with_containers)
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT)
message_id = str(uuid4())
with patch(
"services.message_suggested_questions_adapters.MessageSuggestedQuestionsRuntime.get_suggested_questions",
return_value=["q1", "q2"],
):
response = test_client_with_containers.get(
f"/console/api/apps/{app.id}/chat-messages/{message_id}/suggested-questions",
headers=authenticate_console_client(test_client_with_containers, account),
)
assert response.status_code == 200
assert response.get_json() == {"data": ["q1", "q2"]}
@pytest.mark.parametrize(
("exc", "expected_status", "expected_code"),
("scenario", "expected_status", "expected_code"),
[
(MessageNotExistsError(), 404, "not_found"),
(ConversationNotExistsError(), 404, "not_found"),
(ProviderTokenNotInitError(), 400, "provider_not_initialize"),
(QuotaExceededError(), 400, "provider_quota_exceeded"),
(ModelCurrentlyNotSupportError(), 400, "model_currently_not_support"),
(SuggestedQuestionsAfterAnswerDisabledError(), 403, "app_suggested_questions_after_answer_disabled"),
(Exception(), 500, "internal_server_error"),
("invalid-provider", 200, None),
("missing-message", 404, "not_found"),
("other-account", 404, "not_found"),
("deleted-conversation", 404, "not_found"),
("disabled", 403, "app_suggested_questions_after_answer_disabled"),
("broken-config", 500, "internal_server_error"),
],
)
def test_message_suggested_questions_errors(
exc: Exception,
def test_message_suggested_questions_with_persisted_configuration(
scenario: str,
expected_status: int,
expected_code: str,
expected_code: str | None,
db_session_with_containers: Session,
test_client_with_containers: FlaskClient,
) -> None:
account, tenant = create_console_account_and_tenant(db_session_with_containers)
app = create_console_app(db_session_with_containers, tenant.id, account.id, AppMode.CHAT)
message_id = str(uuid4())
session = db_session_with_containers
account, tenant = create_console_account_and_tenant(session)
app = create_console_app(session, tenant.id, account.id, AppMode.CHAT)
config = AppModelConfig(
app_id=app.id,
suggested_questions_after_answer=(
"not json"
if scenario == "broken-config"
else '{"enabled":false}'
if scenario == "disabled"
else '{"enabled":true}'
),
)
session.add(config)
session.flush()
app.app_model_config_id = config.id
conversation = _create_conversation(session, app.id, account.id, AppMode.CHAT)
conversation.app_model_config_id = config.id
message = _create_message(session, app.id, conversation.id, account.id)
if scenario == "other-account":
message.from_account_id = str(uuid4())
elif scenario == "deleted-conversation":
conversation.is_deleted = True
elif scenario == "invalid-provider":
# Real model resolution rejects the stored ID before contacting the daemon.
session.add(Provider(tenant_id=tenant.id, provider_name="invalid/provider", is_valid=True))
session.commit()
message_id = str(uuid4()) if scenario == "missing-message" else message.id
with patch(
"services.message_suggested_questions_adapters.MessageSuggestedQuestionsRuntime.get_suggested_questions",
side_effect=exc,
):
response = test_client_with_containers.get(
f"/console/api/apps/{app.id}/chat-messages/{message_id}/suggested-questions",
headers=authenticate_console_client(test_client_with_containers, account),
)
response = test_client_with_containers.get(
f"/console/api/apps/{app.id}/chat-messages/{message_id}/suggested-questions",
headers=authenticate_console_client(test_client_with_containers, account),
)
assert response.status_code == expected_status
payload = response.get_json()
assert payload is not None
assert payload["code"] == expected_code
if expected_code is None:
assert payload == {"data": []}
else:
assert payload["code"] == expected_code
def test_message_api_get_success(
@@ -1,4 +1,4 @@
from unittest.mock import ANY, patch
from unittest.mock import patch
import pytest
from faker import Faker
@@ -11,7 +11,6 @@ from services.errors.message import (
FirstMessageNotExistsError,
LastMessageNotExistsError,
MessageNotExistsError,
SuggestedQuestionsAfterAnswerDisabledError,
)
from services.message_service import MessageService
from tests.e2e_tests.containers.helpers import generate_valid_password
@@ -23,53 +22,8 @@ class TestMessageService:
@pytest.fixture
def mock_external_service_dependencies(self):
"""Mock setup for external service dependencies."""
with (
patch("services.account.login_adapters.SystemFeatureService") as mock_account_feature_service,
patch("services.message_service.ModelManager.for_tenant") as mock_model_manager,
patch("services.message_service.WorkflowService") as mock_workflow_service,
patch("services.message_service.AdvancedChatAppConfigManager") as mock_app_config_manager,
patch("services.message_service.LLMGenerator") as mock_llm_generator,
patch("services.message_service.TraceQueueManager") as mock_trace_manager_class,
patch("services.message_service.TokenBufferMemory") as mock_token_buffer_memory,
):
# Setup default mock returns
# Mock ModelManager
mock_model_instance = mock_model_manager.return_value.get_default_model_instance.return_value
mock_model_instance.get_tts_voices.return_value = [{"value": "test-voice"}]
# Mock get_model_instance method as well
mock_model_manager.return_value.get_model_instance.return_value = mock_model_instance
# Mock WorkflowService
mock_workflow = mock_workflow_service.return_value.get_published_workflow.return_value
mock_workflow_service.return_value.get_draft_workflow.return_value = mock_workflow
# Mock AdvancedChatAppConfigManager
mock_app_config = mock_app_config_manager.get_app_config.return_value
mock_app_config.additional_features.suggested_questions_after_answer = True
# Mock LLMGenerator
mock_llm_generator.generate_suggested_questions_after_answer.return_value = ["Question 1", "Question 2"]
# Mock TraceQueueManager
mock_trace_manager_instance = mock_trace_manager_class.return_value
# Mock TokenBufferMemory
mock_memory_instance = mock_token_buffer_memory.return_value
mock_memory_instance.get_history_prompt_text.return_value = "Mocked history prompt"
yield {
"account_feature_service": mock_account_feature_service,
"model_manager": mock_model_manager,
"workflow_service": mock_workflow_service,
"app_config_manager": mock_app_config_manager,
"llm_generator": mock_llm_generator,
"trace_manager_class": mock_trace_manager_class,
"trace_manager_instance": mock_trace_manager_instance,
"token_buffer_memory": mock_token_buffer_memory,
# "current_user": mock_current_user,
}
with patch("services.account.login_adapters.SystemFeatureService") as mock_account_feature_service:
yield {"account_feature_service": mock_account_feature_service}
def _create_test_app_and_account(self, db_session_with_containers: Session, mock_external_service_dependencies):
"""
@@ -104,7 +58,7 @@ class TestMessageService:
app_args = CreateAppParams(
name=fake.company(),
description=fake.text(max_nb_chars=100),
mode="advanced-chat", # Use advanced-chat mode to use mocked workflow,
mode="advanced-chat",
icon_type="emoji",
icon="🤖",
icon_background="#FF6B6B",
@@ -715,170 +669,3 @@ class TestMessageService:
MessageService.get_message(
app_model=app, user=other_account, message_id=message.id, session=db_session_with_containers
)
def test_get_suggested_questions_after_answer_success(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test successful generation of suggested questions after answer.
"""
fake = Faker()
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
# Create a conversation and message
conversation = self._create_test_conversation(db_session_with_containers, app, account, fake)
message = self._create_test_message(db_session_with_containers, app, conversation, account, fake)
# Mock the LLMGenerator to return specific questions
mock_questions = ["What is AI?", "How does machine learning work?", "Tell me about neural networks"]
mock_external_service_dependencies[
"llm_generator"
].generate_suggested_questions_after_answer.return_value = mock_questions
# Get suggested questions
from core.app.entities.app_invoke_entities import InvokeFrom
result = MessageService.get_suggested_questions_after_answer(
app_model=app,
user=account,
message_id=message.id,
invoke_from=InvokeFrom.SERVICE_API,
session=db_session_with_containers,
)
# Verify results
assert result == mock_questions
# Verify LLMGenerator was called
mock_external_service_dependencies[
"llm_generator"
].generate_suggested_questions_after_answer.assert_called_once()
# Verify TraceQueueManager was called
mock_external_service_dependencies["trace_manager_instance"].add_trace_task.assert_called_once()
def test_get_suggested_questions_after_answer_no_user(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test getting suggested questions when no user is provided.
"""
fake = Faker()
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
# Create a conversation and message
conversation = self._create_test_conversation(db_session_with_containers, app, account, fake)
message = self._create_test_message(db_session_with_containers, app, conversation, account, fake)
# Test getting suggested questions with no user
from core.app.entities.app_invoke_entities import InvokeFrom
with pytest.raises(ValueError, match="user cannot be None"):
MessageService.get_suggested_questions_after_answer(
app_model=app,
user=None,
message_id=message.id,
invoke_from=InvokeFrom.SERVICE_API,
session=db_session_with_containers,
)
def test_get_suggested_questions_after_answer_disabled(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test getting suggested questions when feature is disabled.
"""
fake = Faker()
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
# Create a conversation and message
conversation = self._create_test_conversation(db_session_with_containers, app, account, fake)
message = self._create_test_message(db_session_with_containers, app, conversation, account, fake)
# Mock the feature to be disabled
mock_external_service_dependencies[
"app_config_manager"
].get_app_config.return_value.additional_features.suggested_questions_after_answer = False
# Test getting suggested questions when feature is disabled
from core.app.entities.app_invoke_entities import InvokeFrom
with pytest.raises(SuggestedQuestionsAfterAnswerDisabledError):
MessageService.get_suggested_questions_after_answer(
app_model=app,
user=account,
message_id=message.id,
invoke_from=InvokeFrom.SERVICE_API,
session=db_session_with_containers,
)
def test_get_suggested_questions_after_answer_no_workflow(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test getting suggested questions when no workflow exists.
"""
fake = Faker()
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
# Create a conversation and message
conversation = self._create_test_conversation(db_session_with_containers, app, account, fake)
message = self._create_test_message(db_session_with_containers, app, conversation, account, fake)
# Mock no workflow
mock_external_service_dependencies["workflow_service"].return_value.get_published_workflow.return_value = None
# Get suggested questions (should return empty list)
from core.app.entities.app_invoke_entities import InvokeFrom
result = MessageService.get_suggested_questions_after_answer(
app_model=app,
user=account,
message_id=message.id,
invoke_from=InvokeFrom.SERVICE_API,
session=db_session_with_containers,
)
# Verify empty result
assert result == []
def test_get_suggested_questions_after_answer_debugger_mode(
self, db_session_with_containers: Session, mock_external_service_dependencies
):
"""
Test getting suggested questions in debugger mode.
"""
fake = Faker()
app, account = self._create_test_app_and_account(db_session_with_containers, mock_external_service_dependencies)
# Create a conversation and message
conversation = self._create_test_conversation(db_session_with_containers, app, account, fake)
message = self._create_test_message(db_session_with_containers, app, conversation, account, fake)
# Mock questions
mock_questions = ["Debug question 1", "Debug question 2"]
mock_external_service_dependencies[
"llm_generator"
].generate_suggested_questions_after_answer.return_value = mock_questions
# Get suggested questions in debugger mode
from core.app.entities.app_invoke_entities import InvokeFrom
result = MessageService.get_suggested_questions_after_answer(
app_model=app,
user=account,
message_id=message.id,
invoke_from=InvokeFrom.DEBUGGER,
session=db_session_with_containers,
)
# Verify results
assert result == mock_questions
# Verify draft workflow was used instead of published workflow
mock_external_service_dependencies["workflow_service"].return_value.get_draft_workflow.assert_called_once_with(
app_model=app, session=ANY
)
# Verify TraceQueueManager was called
mock_external_service_dependencies["trace_manager_instance"].add_trace_task.assert_called_once()
@@ -0,0 +1,456 @@
"""Exercise real model resolution, generation and failures with PostgreSQL/Redis.
Only provider discovery, decoded credentials and schema data are seeded in their
normal caches. ModelManager and HTTP transport run normally, against either a
loopback provider returning valid SSE or a daemon with a missing installation.
"""
import json
from collections.abc import Iterator
from dataclasses import dataclass
from decimal import Decimal
from http import HTTPStatus
from threading import Thread
import httpx
import pytest
from flask import Flask, Response
from flask.testing import FlaskClient
from sqlalchemy import Connection, delete, event, select, update
from sqlalchemy.orm import Session, SessionTransaction
from werkzeug.serving import make_server
from yarl import URL
from configs import dify_config
from core.helper.model_provider_cache import ProviderCredentialsCache, ProviderCredentialsCacheType
from core.model_context import get_credit_usage_metadata
from core.model_manager import ModelManager
from core.ops import ops_trace_manager
from core.plugin.entities.plugin_daemon import PluginModelProviderDeclaration
from core.plugin.impl import base as plugin_base
from core.plugin.impl.model_runtime_factory import create_plugin_model_runtime
from core.plugin.plugin_service import PluginService
from extensions.ext_database import db
from extensions.ext_redis import redis_client
from graphon.model_runtime.entities.common_entities import I18nObject
from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFrom, ModelType
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod
from models.account import Account
from models.enums import ConversationFromSource
from models.model import App, AppMode, AppModelConfig, Conversation, Message
from models.provider import LoadBalancingModelConfig, Provider, ProviderModelSetting, TenantDefaultModel
from services.entities.message_entities import MessageAccount
from services.message_suggested_questions_generator import SuggestedQuestionsGenerator
from services.message_suggested_questions_service import SuggestedQuestionsContext
from tests.e2e_tests.containers.controllers.console.helpers import (
authenticate_console_client,
create_console_account_and_tenant,
create_console_app,
)
_PROVIDER = "tests/suggested/suggested"
_MODEL = "questions-model"
@dataclass(frozen=True)
class _Scenario:
account: Account
context: SuggestedQuestionsContext
@pytest.fixture
def scenario(db_session_with_containers: Session, monkeypatch: pytest.MonkeyPatch) -> Iterator[_Scenario]:
monkeypatch.setattr(dify_config, "PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED", True)
session = db_session_with_containers
account, tenant = create_console_account_and_tenant(session)
app = create_console_app(session, tenant.id, account.id, AppMode.CHAT)
config = AppModelConfig(app_id=app.id, suggested_questions_after_answer='{"enabled":true}')
session.add(config)
session.flush()
app.app_model_config_id = config.id
conversation = Conversation(
app_id=app.id,
app_model_config_id=config.id,
mode=AppMode.CHAT,
name="Suggested questions",
_inputs={},
from_source=ConversationFromSource.CONSOLE,
from_account_id=account.id,
)
session.add(conversation)
session.flush()
message = Message(
app_id=app.id,
conversation_id=conversation.id,
_inputs={},
query="How does this work?",
message={},
answer="Read the documentation.",
message_unit_price=Decimal(0),
answer_unit_price=Decimal(0),
currency="USD",
from_source=ConversationFromSource.CONSOLE,
from_account_id=account.id,
)
provider = Provider(tenant_id=tenant.id, provider_name=_PROVIDER, is_valid=True)
session.add_all([message, provider])
session.flush()
context = SuggestedQuestionsContext(
app_id=app.id,
tenant_id=tenant.id,
app_mode=AppMode.CHAT,
message_id=message.id,
conversation_id=conversation.id,
actor=MessageAccount(account.id),
invoke_from="debugger",
config={"enabled": True},
)
session.add(
TenantDefaultModel(tenant_id=tenant.id, provider_name=_PROVIDER, model_name=_MODEL, model_type=ModelType.LLM)
)
schema = AIModelEntity(
model=_MODEL,
label=I18nObject(en_US="Questions"),
model_type=ModelType.LLM,
fetch_from=FetchFrom.PREDEFINED_MODEL,
model_properties={},
parameter_rules=[],
)
declaration = PluginModelProviderDeclaration(
provider=_PROVIDER,
plugin_unique_identifier="tests/suggested:0.0.1@" + "0" * 64,
installation_source=None,
label=I18nObject(en_US="Suggested questions"),
supported_model_types=[ModelType.LLM],
configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL],
models=[schema],
)
credentials = {"api_key": "unused-test-key"}
PluginService._store_cached_plugin_model_providers(tenant.id, 0, [declaration])
ProviderCredentialsCache(tenant.id, provider.id, ProviderCredentialsCacheType.PROVIDER).set(credentials)
redis_client.setex(f"tenant:{tenant.id}:model_load_balancing_enabled", 60, "False")
runtime = create_plugin_model_runtime(tenant_id=tenant.id)
runtime.bind_provider_schema(declaration)
schema_key = runtime._get_schema_cache_key(
provider=_PROVIDER, model_type=ModelType.LLM, model=_MODEL, credentials=credentials
)
redis_client.setex(schema_key, 60, schema.model_dump_json())
session.commit()
# The container fixture reloads config after this module-level URL was bound.
monkeypatch.setattr(plugin_base, "plugin_daemon_inner_api_baseurl", URL(str(dify_config.PLUGIN_DAEMON_URL)))
previous_timer = ops_trace_manager.trace_manager_timer
try:
yield _Scenario(account=account, context=context)
finally:
timer = ops_trace_manager.trace_manager_timer
if timer is not None and timer is not previous_timer:
timer.cancel()
timer.join(timeout=1)
ops_trace_manager.trace_manager_timer = previous_timer
@pytest.fixture
def _successful_model_provider(scenario: _Scenario, monkeypatch: pytest.MonkeyPatch) -> Iterator[None]:
app = Flask(__name__)
route = f"/plugin/{scenario.context.tenant_id}/dispatch/llm"
@app.post(f"{route}/num_tokens")
def count_tokens() -> Response:
payload = {"code": 0, "message": "success", "data": {"num_tokens": 8}}
return Response(f"data: {json.dumps(payload)}\n\n", mimetype="text/event-stream")
@app.post(f"{route}/invoke")
def invoke() -> Response:
def chunks() -> Iterator[str]:
for index, content in enumerate(
('```json\n["What should I try next?",', '"Can you show an example?"]\n```')
):
payload = {
"code": 0,
"message": "success",
"data": {
"model": _MODEL,
"delta": {
"index": index,
"message": {"role": "assistant", "content": content},
"finish_reason": "stop" if index == 1 else None,
},
},
}
yield f"data: {json.dumps(payload)}\n\n"
return Response(chunks(), mimetype="text/event-stream")
server = make_server("127.0.0.1", 0, app, threaded=True)
thread = Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True)
thread.start()
monkeypatch.setattr(plugin_base, "plugin_daemon_inner_api_baseurl", URL(f"http://127.0.0.1:{server.server_port}"))
try:
yield
finally:
server.shutdown()
thread.join()
server.server_close()
@pytest.mark.parametrize("configured_model", [False, True], ids=["default-model", "configured-model"])
@pytest.mark.usefixtures("_successful_model_provider")
def test_console_returns_generated_questions_through_real_http_provider(
scenario: _Scenario,
test_client_with_containers: FlaskClient,
monkeypatch: pytest.MonkeyPatch,
configured_model: bool,
) -> None:
feature: dict[str, object] = {"enabled": True, "prompt": "Give two concrete follow-ups."}
if configured_model:
feature["model"] = {"provider": _PROVIDER, "name": _MODEL, "completion_params": {"temperature": 0.2}}
db.session.execute(
update(AppModelConfig)
.where(AppModelConfig.app_id == scenario.context.app_id)
.values(suggested_questions_after_answer=json.dumps(feature))
)
db.session.commit()
headers = authenticate_console_client(test_client_with_containers, scenario.account)
monkeypatch.setattr(dify_config, "PLUGIN_BASED_TOKEN_COUNTING_ENABLED", True)
db.session.remove()
caller = db.session()
sessions: list[Session] = []
requests: list[httpx.Request] = []
active_sessions: list[Session] = []
def remember_session(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
sessions.append(session)
def observe_request(request: httpx.Request) -> None:
requests.append(request)
active_sessions.extend(session for session in sessions if session is not caller and session.in_transaction())
event.listen(Session, "after_begin", remember_session)
hooks = plugin_base._httpx_client.event_hooks["request"]
hooks.append(observe_request)
try:
response = test_client_with_containers.get(
f"/console/api/apps/{scenario.context.app_id}/chat-messages/"
f"{scenario.context.message_id}/suggested-questions",
headers=headers,
)
finally:
hooks.remove(observe_request)
event.remove(Session, "after_begin", remember_session)
assert response.status_code == HTTPStatus.OK
assert response.headers["Content-Type"] == "application/json"
assert response.get_json() == {"data": ["What should I try next?", "Can you show an example?"]}
assert [request.url.path for request in requests] == [
f"/plugin/{scenario.context.tenant_id}/dispatch/llm/num_tokens",
f"/plugin/{scenario.context.tenant_id}/dispatch/llm/invoke",
]
assert not active_sessions
invocation = requests[1]
payload = json.loads(invocation.content)["data"]
assert payload["provider"] == "suggested"
assert payload["model"] == _MODEL
assert payload["credentials"] == {"api_key": "unused-test-key"}
assert payload["stream"] is False
assert payload["model_parameters"] == (
{"temperature": 0.2} if configured_model else {"max_tokens": 256, "temperature": 0.0}
)
assert len(payload["prompt_messages"]) == 1
prompt = payload["prompt_messages"][0]
assert prompt["role"] == "user"
assert "Human: How does this work?\nAssistant: Read the documentation." in prompt["content"]
assert "Give two concrete follow-ups." in prompt["content"]
assert 'You must output a JSON array like ["question1", "question2", "question3"].' in prompt["content"]
assert invocation.headers["X-Plugin-ID"] == "tests/suggested"
assert invocation.headers["X-Plugin-Unique-Identifier"] == "tests/suggested:0.0.1@" + "0" * 64
@pytest.mark.parametrize("configured_provider", [_PROVIDER, "tests/missing/missing"])
def test_configured_model_resolution_and_default_fallback(scenario: _Scenario, configured_provider: str) -> None:
prepared = SuggestedQuestionsGenerator._prepare_model(
tenant_id=scenario.context.tenant_id,
model_config={"provider": configured_provider, "name": _MODEL, "completion_params": {"temperature": 0.2}},
)
assert prepared is not None
assert prepared.model_instance.provider == _PROVIDER
assert prepared.model_instance.model_name == _MODEL
assert prepared.model_instance.credentials == {"api_key": "unused-test-key"}
assert prepared.completion_params == ({"temperature": 0.2} if configured_provider == _PROVIDER else None)
def test_unavailable_configured_and_default_models_return_none(scenario: _Scenario) -> None:
db.session.execute(
update(TenantDefaultModel)
.where(TenantDefaultModel.tenant_id == scenario.context.tenant_id)
.values(provider_name="tests/missing/missing")
)
db.session.commit()
generator = SuggestedQuestionsGenerator()
assert (
generator._prepare_model(
tenant_id=scenario.context.tenant_id, model_config={"provider": "tests/missing/missing", "name": _MODEL}
)
is None
)
with generator.prepare(context=scenario.context, instruction_prompt=None, model_config=None) as generate:
assert generate is None
def test_missing_default_model_does_not_create_one_or_commit_caller_changes(scenario: _Scenario) -> None:
db.session.execute(delete(TenantDefaultModel).where(TenantDefaultModel.tenant_id == scenario.context.tenant_id))
db.session.commit()
app = db.session.get(App, scenario.context.app_id)
assert app is not None
original_name = app.name
app.name = "Uncommitted caller change"
caller = db.session()
try:
with SuggestedQuestionsGenerator().prepare(
context=scenario.context, instruction_prompt=None, model_config=None
) as generate:
assert generate is None
assert db.session() is not caller
assert not db.session().in_transaction()
with Session(db.engine) as verification:
default = verification.scalar(
select(TenantDefaultModel).where(TenantDefaultModel.tenant_id == scenario.context.tenant_id)
)
assert default is None
assert verification.scalar(select(App.name).where(App.id == app.id)) == original_name
assert db.session() is caller
assert app in caller.dirty
assert app.name == "Uncommitted caller change"
finally:
caller.rollback()
@pytest.mark.parametrize("token_counting", [True, False])
def test_console_missing_plugin_token_error_propagates_but_invocation_returns_empty(
scenario: _Scenario,
test_client_with_containers: FlaskClient,
monkeypatch: pytest.MonkeyPatch,
caplog: pytest.LogCaptureFixture,
token_counting: bool,
) -> None:
headers = authenticate_console_client(test_client_with_containers, scenario.account)
monkeypatch.setattr(dify_config, "PLUGIN_BASED_TOKEN_COUNTING_ENABLED", token_counting)
db.session.remove()
caller = db.session()
sessions: list[Session] = []
requests: list[httpx.Request] = []
active_sessions: list[Session] = []
metadata: list[dict[str, object]] = []
def remember_session(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
sessions.append(session)
def observe_request(request: httpx.Request) -> None:
requests.append(request)
active_sessions.extend(session for session in sessions if session is not caller and session.in_transaction())
metadata.append(dict(get_credit_usage_metadata() or {}))
event.listen(Session, "after_begin", remember_session)
hooks = plugin_base._httpx_client.event_hooks["request"]
hooks.append(observe_request)
try:
response = test_client_with_containers.get(
f"/console/api/apps/{scenario.context.app_id}/chat-messages/"
f"{scenario.context.message_id}/suggested-questions",
headers=headers,
)
finally:
hooks.remove(observe_request)
event.remove(Session, "after_begin", remember_session)
assert len(requests) == 1
suffix = "num_tokens" if token_counting else "invoke"
assert requests[0].url.path == f"/plugin/{scenario.context.tenant_id}/dispatch/llm/{suffix}"
assert not active_sessions
errors = [record.exc_info[1] for record in caplog.records if record.exc_info and record.exc_info[1] is not None]
# The daemon's -404 JSON response is translated to ValueError by the
# streaming transport; only -500 responses deserialize a plugin exception.
assert any(
isinstance(error, ValueError) and "plugin not found" in str(error) and "code: -404" in str(error)
for error in errors
)
payload = response.get_json()
assert isinstance(payload, dict)
if token_counting:
assert response.status_code == HTTPStatus.INTERNAL_SERVER_ERROR
assert payload["code"] == "internal_server_error"
else:
assert response.status_code == HTTPStatus.OK
assert payload == {"data": []}
assert requests[0].extensions["timeout"] == {"connect": 30.0, "read": 30.0, "write": 30.0, "pool": 30.0}
assert metadata == [{"app_type": "chatbot", "created_by": "suggested_questions"}]
def test_console_token_counting_reports_credentials_in_cooldown(
scenario: _Scenario,
test_client_with_containers: FlaskClient,
monkeypatch: pytest.MonkeyPatch,
) -> None:
tenant_id = scenario.context.tenant_id
balancing = LoadBalancingModelConfig(
tenant_id=tenant_id, provider_name=_PROVIDER, model_name=_MODEL, model_type=ModelType.LLM, name="__inherit__"
)
backup = LoadBalancingModelConfig(
tenant_id=tenant_id,
provider_name=_PROVIDER,
model_name=_MODEL,
model_type=ModelType.LLM,
name="Backup credentials",
encrypted_config='{"api_key":"unused-test-key"}',
)
db.session.add_all(
[
balancing,
backup,
ProviderModelSetting(
tenant_id=tenant_id,
provider_name=_PROVIDER,
model_name=_MODEL,
model_type=ModelType.LLM,
load_balancing_enabled=True,
),
]
)
db.session.commit()
ProviderCredentialsCache(tenant_id, backup.id, ProviderCredentialsCacheType.LOAD_BALANCING_MODEL).set(
{"api_key": "unused-test-key"}
)
redis_client.setex(f"tenant:{tenant_id}:model_load_balancing_enabled", 60, "True")
model = ModelManager.for_tenant(tenant_id=tenant_id).get_default_model_instance(
tenant_id=tenant_id, model_type=ModelType.LLM
)
manager = model.load_balancing_manager
assert manager is not None
assert {configuration.id for configuration in manager._load_balancing_configs} == {balancing.id, backup.id}
for configuration in manager._load_balancing_configs:
manager.cooldown(configuration)
assert manager.in_cooldown(configuration)
monkeypatch.setattr(dify_config, "PLUGIN_BASED_TOKEN_COUNTING_ENABLED", True)
headers = authenticate_console_client(test_client_with_containers, scenario.account)
db.session.remove()
requests: list[httpx.Request] = []
hooks = plugin_base._httpx_client.event_hooks["request"]
hooks.append(requests.append)
try:
response = test_client_with_containers.get(
f"/console/api/apps/{scenario.context.app_id}/chat-messages/"
f"{scenario.context.message_id}/suggested-questions",
headers=headers,
)
finally:
hooks.remove(requests.append)
assert requests == []
assert response.status_code == HTTPStatus.BAD_REQUEST
payload = response.get_json()
assert isinstance(payload, dict)
assert payload["code"] == "provider_not_initialize"
assert payload["message"] == "Model credentials is not initialized."
@@ -0,0 +1,108 @@
"""Suggested-question tuning using real model objects and the Redis schema cache."""
from uuid import uuid4
import pytest
from flask import Flask
from core.entities.provider_configuration import ProviderConfiguration, ProviderModelBundle
from core.entities.provider_entities import CustomConfiguration, SystemConfiguration
from core.model_manager import ModelInstance
from core.plugin.impl.model_runtime_factory import create_plugin_model_runtime
from extensions.ext_redis import redis_client
from graphon.model_runtime.entities.common_entities import I18nObject
from graphon.model_runtime.entities.model_entities import (
AIModelEntity,
FetchFrom,
ModelType,
ParameterRule,
ParameterType,
)
from graphon.model_runtime.entities.provider_entities import ProviderEntity
from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel
from models.provider import ProviderType
from services.message_suggested_questions_generator import _default_suggested_questions_model_parameters
@pytest.mark.parametrize(
("thinking_type", "thinking_options", "reasoning_options", "expected"),
[
(None, [], [], {}),
(None, [], ["minimal", "low", "medium", "high"], {"reasoning_effort": "minimal"}),
(None, [], ["low", "none", "minimal"], {"reasoning_effort": "none"}),
(None, [], ["low", "high"], {"reasoning_effort": "low"}),
(None, [], ["medium", "high"], {}),
(ParameterType.BOOLEAN, [], ["low", "high"], {"thinking": False}),
(ParameterType.STRING, ["enabled", "disabled"], ["low", "high"], {"thinking": "disabled"}),
(ParameterType.STRING, ["enabled"], ["low", "high"], {"reasoning_effort": "low"}),
],
)
def test_default_model_parameters_use_cached_provider_schema(
flask_app_with_containers: Flask,
thinking_type: ParameterType | None,
thinking_options: list[str],
reasoning_options: list[str],
expected: dict[str, object],
) -> None:
tenant_id = str(uuid4())
provider = ProviderEntity(
provider="langgenius/openai/openai",
label=I18nObject(en_US="OpenAI"),
supported_model_types=[ModelType.LLM],
configurate_methods=[],
)
rules = []
if thinking_type is not None:
rules.append(
ParameterRule(
name="thinking", label=I18nObject(en_US="Thinking"), type=thinking_type, options=thinking_options
)
)
if reasoning_options:
rules.append(
ParameterRule(
name="reasoning_effort",
label=I18nObject(en_US="Reasoning effort"),
type=ParameterType.STRING,
options=reasoning_options,
)
)
schema = AIModelEntity(
model="questions-model",
label=I18nObject(en_US="Questions model"),
model_type=ModelType.LLM,
fetch_from=FetchFrom.PREDEFINED_MODEL,
model_properties={},
parameter_rules=rules,
)
runtime = create_plugin_model_runtime(tenant_id=tenant_id)
model = ModelInstance(
provider_model_bundle=ProviderModelBundle(
configuration=ProviderConfiguration(
tenant_id=tenant_id,
provider=provider,
preferred_provider_type=ProviderType.CUSTOM,
using_provider_type=ProviderType.CUSTOM,
system_configuration=SystemConfiguration(enabled=False),
custom_configuration=CustomConfiguration(provider=None),
model_settings=[],
),
model_type_instance=LargeLanguageModel(provider_schema=provider, model_runtime=runtime),
),
model=schema.model,
credentials={},
)
cache_key = runtime._get_schema_cache_key(
provider=provider.provider, model_type=ModelType.LLM, model=schema.model, credentials={}
)
with flask_app_with_containers.app_context():
redis_client.setex(cache_key, 60, schema.model_dump_json())
try:
assert model.get_model_schema() == schema
assert _default_suggested_questions_model_parameters(model) == {
"max_tokens": 256,
"temperature": 0.0,
**expected,
}
finally:
redis_client.delete(cache_key)
@@ -0,0 +1,510 @@
"""Console suggested-question admission and actor/configuration isolation over SQLite."""
import json
from collections.abc import Iterator
from dataclasses import dataclass
from decimal import Decimal
from http import HTTPStatus
from uuid import uuid4
import pytest
from flask import Flask
from sqlalchemy import Connection, Engine, delete, event, func, select, text, update
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
from werkzeug.test import TestResponse
import controllers.console.wraps as console_wraps
from controllers.console.app import message as controller
from enums import DeploymentEdition
from extensions.application_services.app import AppServices, build_app_services
from extensions.application_services.rbac import RBACServices
from extensions.ext_application_services import _build_oauth_server_service
from extensions.ext_database import db
from extensions.ext_redis import RedisClientWrapper
from graphon.model_runtime.entities.model_entities import ModelType
from libs import login
from libs.external_api import ExternalApi
from models.account import Account, Tenant
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigVersionKind,
AgentDebugConversation,
AgentScope,
AgentSource,
AgentStatus,
AgentWorkspaceBinding,
)
from models.agent_config_entities import AgentSoulConfig
from models.enums import ConversationFromSource
from models.model import App, AppMode, AppModelConfig, Conversation, DifySetup, Message
from models.provider import Provider, TenantDefaultModel
from repositories.app.agent_app_repository import AgentAppRepository
from repositories.app.console_repository import ConsoleAppRepository
from repositories.app.tracing_config_repository import SQLAlchemyAppTracingConfigRepository
from repositories.message_repository import MessageRepository
from repositories.recommended_app_catalog_repository import DatabaseRecommendedAppCatalogRepository
from services.agent.roster_package_exporter import RosterAgentPackageExporter
from services.app.agent_app_service import AgentAppAccessService
from services.app_tracing_config_gateway import OpsTraceManagerGateway
from services.app_tracing_config_service import AppTracingConfigService
from services.entities.message_entities import MessageAccount
from services.message_suggested_questions_generator import SuggestedQuestionsGenerator
from services.message_suggested_questions_queries import SuggestedQuestionsQuery
from services.message_suggested_questions_service import (
MessageSuggestedQuestions,
MessageSuggestedQuestionsService,
)
from services.recommended_app_package_service import RecommendedAppPackageService
from tests.helpers.unit.config_override import apply_config_overrides
from tests.helpers.unit.model_factories import make_account, make_app, make_conversation, make_message
from tests.helpers.unit.rbac_fakes import RBACDomain
@dataclass
class _Agents:
access: AgentAppAccessService
@dataclass
class _Services:
apps: AppServices
agent_apps: _Agents
message_suggested_questions: MessageSuggestedQuestions
rbac: RBACServices
@dataclass
class _Harness:
flask_app: Flask
target: App
account: Account
conversation: Conversation
message: Message
agent_id: str
factory: sessionmaker[Session]
services: _Services
sessions: list[Session]
queries: SuggestedQuestionsQuery
def get(self, route: str = "app") -> TestResponse:
resource = f"apps/{self.target.id}" if route == "app" else f"agent/{self.agent_id}"
return self.flask_app.test_client().get(f"/{resource}/chat-messages/{self.message.id}/suggested-questions")
def add_agent(self, *, scope: AgentScope = AgentScope.ROSTER, backing: bool = True) -> None:
with self.factory.begin() as session:
session.execute(update(App).where(App.id == self.target.id).values(mode=AppMode.AGENT))
session.add(
Agent(
id=self.agent_id,
tenant_id=self.target.tenant_id,
name="Agent",
scope=scope,
source=AgentSource.AGENT_APP if scope == AgentScope.ROSTER else AgentSource.WORKFLOW,
status=AgentStatus.ACTIVE,
app_id=self.target.id if scope == AgentScope.ROSTER else str(uuid4()),
backing_app_id=self.target.id if scope == AgentScope.WORKFLOW_ONLY and backing else None,
workflow_id=str(uuid4()) if scope == AgentScope.WORKFLOW_ONLY else None,
workflow_node_id="node" if scope == AgentScope.WORKFLOW_ONLY else None,
)
)
@pytest.fixture
def harness(
_empty_model_provider_daemon: None,
monkeypatch: pytest.MonkeyPatch,
sqlite_engine: Engine,
sqlite_session_factory: sessionmaker[Session],
rbac_domain: RBACDomain,
) -> Iterator[_Harness]:
target = make_app(app_id=str(uuid4()), tenant_id=str(uuid4()))
account = make_account(account_id=str(uuid4()))
tenant = Tenant(name="Workspace")
tenant.id = target.tenant_id
account._current_tenant = tenant
conversation = make_conversation(
conversation_id=str(uuid4()),
app_id=target.id,
from_source=ConversationFromSource.CONSOLE,
from_account_id=account.id,
inputs={},
)
config = AppModelConfig(
app_id=target.id, suggested_questions_after_answer=json.dumps({"enabled": True, "prompt": "Console questions"})
)
message = make_message(
message_id=str(uuid4()),
app_id=target.id,
conversation_id=conversation.id,
inputs={},
message={},
query="How?",
answer="Like this.",
message_unit_price=Decimal(0),
answer_unit_price=Decimal(0),
currency="USD",
from_source=ConversationFromSource.CONSOLE,
from_account_id=account.id,
)
with sqlite_session_factory.begin() as session:
session.add_all(
[
target,
account,
conversation,
config,
message,
DifySetup(version="test"),
Provider(tenant_id=target.tenant_id, provider_name="invalid/provider", is_valid=True),
TenantDefaultModel(
tenant_id=target.tenant_id,
provider_name="invalid/provider",
model_name="invalid-model",
model_type=ModelType.LLM,
),
]
)
session.flush()
target.app_model_config_id = config.id
conversation.app_model_config_id = config.id
sessions: list[Session] = []
def track_session(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
sessions.append(session)
event.listen(sqlite_session_factory, "after_begin", track_session)
monkeypatch.setattr(login, "current_user", account)
console_wraps._is_setup_completed.reset_success()
apply_config_overrides(
monkeypatch, LOGIN_DISABLED=True, RBAC_ENABLED=False, DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY
)
redis = RedisClientWrapper()
app_services = build_app_services(
database_client=sqlite_session_factory,
repository=ConsoleAppRepository(session_factory=sqlite_session_factory),
rbac_members=rbac_domain.rbac.members,
tracing_configs=AppTracingConfigService(
configs=SQLAlchemyAppTracingConfigRepository(session_factory=sqlite_session_factory),
provider=OpsTraceManagerGateway(),
),
oauth=_build_oauth_server_service(database_client=sqlite_session_factory, redis=redis),
recommended_packages=RecommendedAppPackageService(
sources=DatabaseRecommendedAppCatalogRepository(sqlite_session_factory, redis=redis),
exporter=RosterAgentPackageExporter(),
),
)
flask_app = Flask(__name__)
flask_app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False, SQLALCHEMY_DATABASE_URI=str(sqlite_engine.url))
db.init_app(flask_app)
queries = SuggestedQuestionsQuery(session_factory=sqlite_session_factory, repository_factory=MessageRepository)
services = _Services(
app_services,
_Agents(AgentAppAccessService(references=AgentAppRepository(session_factory=sqlite_session_factory))),
MessageSuggestedQuestionsService(queries=queries, generator=SuggestedQuestionsGenerator()),
rbac_domain.rbac,
)
flask_app.extensions["application_services"] = services
api = ExternalApi(flask_app)
api.add_resource(
controller.MessageSuggestedQuestionApi,
"/apps/<uuid:app_id>/chat-messages/<uuid:message_id>/suggested-questions",
)
api.add_resource(
controller.AgentMessageSuggestedQuestionApi,
"/agent/<uuid:agent_id>/chat-messages/<uuid:message_id>/suggested-questions",
)
result = _Harness(
flask_app,
target,
account,
conversation,
message,
str(uuid4()),
sqlite_session_factory,
services,
sessions,
queries,
)
yield result
event.remove(sqlite_session_factory, "after_begin", track_session)
console_wraps._is_setup_completed.reset_success()
with flask_app.app_context():
db.session.remove()
db.engine.dispose()
@pytest.mark.parametrize("mode", [AppMode.CHAT, AppMode.AGENT_CHAT])
def test_model_resolution_failure_keeps_empty_http_success(
harness: _Harness, mode: AppMode, caplog: pytest.LogCaptureFixture
) -> None:
with harness.factory.begin() as session:
session.execute(update(App).where(App.id == harness.target.id).values(mode=mode))
response = harness.get()
assert response.status_code == HTTPStatus.OK
assert response.headers["Content-Type"] == "application/json"
assert response.get_json() == {"data": []}
assert any(
record.exc_info
and isinstance(record.exc_info[1], ValueError)
and "Invalid provider: invalid/provider" in str(record.exc_info[1])
for record in caplog.records
)
assert all(not session.in_transaction() and not session.identity_map for session in harness.sessions)
def test_advanced_chat_without_draft_workflow_returns_empty_success(harness: _Harness) -> None:
with harness.factory.begin() as session:
session.execute(update(App).where(App.id == harness.target.id).values(mode=AppMode.ADVANCED_CHAT))
response = harness.get()
assert response.status_code == HTTPStatus.OK
assert response.get_json() == {"data": []}
@pytest.mark.parametrize("change", ["tenant", "status", "missing", "workflow", "completion", "hidden"])
def test_app_admission_rejects_unavailable_or_unsupported_apps(harness: _Harness, change: str) -> None:
if change == "hidden":
harness.add_agent(scope=AgentScope.WORKFLOW_ONLY)
else:
field_name, value = {
"tenant": ("tenant_id", str(uuid4())),
"status": ("status", "archived"),
"missing": ("id", str(uuid4())),
"workflow": ("mode", "workflow"),
"completion": ("mode", "completion"),
}[change]
with harness.factory.begin() as session:
if change == "status":
session.execute(
text("UPDATE apps SET status = :status WHERE id = :id"), {"status": value, "id": harness.target.id}
)
else:
session.execute(update(App).where(App.id == harness.target.id).values({field_name: value}))
response = harness.get()
assert response.status_code == HTTPStatus.NOT_FOUND
assert response.get_json()["code"] == "app_not_found"
@pytest.mark.parametrize("scope", [AgentScope.ROSTER, AgentScope.WORKFLOW_ONLY])
def test_agent_resolves_existing_backing_app_with_its_debug_configuration(harness: _Harness, scope: AgentScope) -> None:
harness.add_agent(scope=scope)
with harness.factory.begin() as session:
session.add(
AgentDebugConversation(
tenant_id=harness.target.tenant_id,
agent_id=harness.agent_id,
app_id=harness.target.id,
account_id=harness.account.id,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
conversation_id=harness.conversation.id,
)
)
session.add(
AgentConfigDraft(
tenant_id=harness.target.tenant_id,
agent_id=harness.agent_id,
account_id=harness.account.id,
draft_owner_key=harness.account.id,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
config_snapshot=AgentSoulConfig.model_validate(
{"app_features": {"suggested_questions_after_answer": {"enabled": True}}}
),
)
)
response = harness.get("agent")
assert response.status_code == HTTPStatus.OK
assert response.get_json() == {"data": []}
assert all(not session.in_transaction() and not session.identity_map for session in harness.sessions)
@pytest.mark.parametrize("change", ["missing_backing", "tenant", "agent_status", "app_status", "app_mode"])
def test_agent_rejects_unavailable_runtime_without_creating_app(harness: _Harness, change: str) -> None:
harness.add_agent(scope=AgentScope.WORKFLOW_ONLY, backing=change != "missing_backing")
if change != "missing_backing":
model, key, value = {
"tenant": (Agent, "tenant_id", str(uuid4())),
"agent_status": (Agent, "status", AgentStatus.ARCHIVED),
"app_status": (App, "status", "archived"),
"app_mode": (App, "mode", AppMode.CHAT),
}[change]
with harness.factory.begin() as session:
if change == "app_status":
session.execute(
text("UPDATE apps SET status = :status WHERE id = :id"), {"status": value, "id": harness.target.id}
)
else:
session.execute(update(model).values({key: value}))
response = harness.get("agent")
assert response.status_code == HTTPStatus.NOT_FOUND
assert response.get_json()["code"] == "agent_not_found_error"
with harness.factory() as session:
assert session.scalar(select(func.count()).select_from(App)) == 1
assert session.scalar(select(func.count()).select_from(AppModelConfig)) == 1
if change == "missing_backing":
assert session.scalar(select(Agent.backing_app_id)) is None
@pytest.mark.parametrize("route", ["app", "agent"])
def test_deleted_account_has_explicit_unauthorized_response(harness: _Harness, route: str) -> None:
if route == "agent":
harness.add_agent()
with harness.factory.begin() as session:
session.execute(delete(Account).where(Account.id == harness.account.id))
response = harness.get(route)
assert response.status_code == HTTPStatus.UNAUTHORIZED
assert response.get_json()["code"] == "unauthorized"
assert response.get_json()["message"] == "Account no longer exists"
assert response.headers["WWW-Authenticate"] == 'Bearer realm="api"'
def test_disabled_suggestions_have_specific_http_error(harness: _Harness) -> None:
with harness.factory.begin() as session:
session.execute(update(AppModelConfig).values(suggested_questions_after_answer='{"enabled":false}'))
response = harness.get()
assert response.status_code == HTTPStatus.FORBIDDEN
assert response.get_json()["code"] == "app_suggested_questions_after_answer_disabled"
@pytest.mark.parametrize("route", ["app", "agent"])
def test_missing_published_agent_version_preserves_specific_http_error(harness: _Harness, route: str) -> None:
harness.add_agent()
response = harness.get(route)
assert response.status_code == HTTPStatus.NOT_FOUND
assert response.headers["Content-Type"] == "application/json"
assert response.get_json() == {
"code": "agent_version_not_found_error",
"message": "Agent config version not found.",
"status": HTTPStatus.NOT_FOUND,
}
assert all(not session.in_transaction() and not session.identity_map for session in harness.sessions)
def test_model_resolution_preserves_pending_caller_session(harness: _Harness) -> None:
with harness.flask_app.app_context():
assert console_wraps._is_setup_completed()
db.session.remove()
caller_session = db.session()
caller_app = caller_session.get(App, harness.target.id)
assert caller_app is not None
caller_app.name = "Pending caller edit"
response = harness.get()
assert db.session() is caller_session
assert caller_session.in_transaction()
assert caller_app in caller_session.dirty
assert response.status_code == HTTPStatus.OK
assert response.get_json() == {"data": []}
@pytest.mark.parametrize("entity", [Message, Conversation])
@pytest.mark.parametrize("change", ["app_id", "from_account_id", "from_source"])
def test_runtime_cannot_read_another_accounts_message_or_conversation(
harness: _Harness, entity: type[Message] | type[Conversation], change: str
) -> None:
with harness.factory.begin() as session:
session.execute(update(entity).values({change: "api" if change == "from_source" else str(uuid4())}))
response = harness.get()
assert response.status_code == HTTPStatus.NOT_FOUND
assert response.get_json()["message"] == ("Message not found" if entity is Message else "Conversation not found")
def test_agent_runtime_uses_current_accounts_personal_debug_draft(harness: _Harness) -> None:
harness.add_agent()
with harness.factory.begin() as session:
session.execute(update(App).where(App.id == harness.target.id).values({App.app_model_config_id: None}))
session.add(
AgentDebugConversation(
tenant_id=harness.target.tenant_id,
agent_id=harness.agent_id,
app_id=harness.target.id,
account_id=harness.account.id,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
conversation_id=harness.conversation.id,
)
)
for owner, prompt in [(harness.account.id, "My personal draft"), (str(uuid4()), "Other accounts secret draft")]:
session.add(
AgentConfigDraft(
tenant_id=harness.target.tenant_id,
agent_id=harness.agent_id,
account_id=owner,
draft_owner_key=owner,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
config_snapshot=AgentSoulConfig.model_validate(
{"app_features": {"suggested_questions_after_answer": {"enabled": True, "prompt": prompt}}}
),
)
)
response = harness.get("agent")
assert response.status_code == HTTPStatus.OK
assert response.get_json() == {"data": []}
context = harness.queries.prepare(
app_id=harness.target.id,
app_owner_tenant_id=harness.target.tenant_id,
expected_app_mode=AppMode.AGENT,
actor=MessageAccount(harness.account.id),
invoke_from="debugger",
message_id=harness.message.id,
)
assert context is not None
assert context.config["prompt"] == "My personal draft"
@pytest.mark.parametrize("same_tenant", [True, False])
def test_agent_runtime_preserves_conversation_binding_scope(harness: _Harness, same_tenant: bool) -> None:
harness.add_agent()
draft_id, binding_id = str(uuid4()), str(uuid4())
with harness.factory.begin() as session:
session.execute(update(App).where(App.id == harness.target.id).values({App.app_model_config_id: None}))
session.add(
AgentConfigDraft(
id=draft_id,
tenant_id=harness.target.tenant_id,
agent_id=harness.agent_id,
draft_type=AgentConfigDraftType.DRAFT,
config_snapshot=AgentSoulConfig.model_validate(
{
"app_features": {
"suggested_questions_after_answer": {
"enabled": True,
"prompt": "Conversation bound questions",
}
}
}
),
)
)
session.add(
AgentWorkspaceBinding(
id=binding_id,
tenant_id=harness.target.tenant_id if same_tenant else str(uuid4()),
app_id=harness.target.id,
workspace_id=str(uuid4()),
agent_id=harness.agent_id,
agent_config_version_id=draft_id,
agent_config_version_kind=AgentConfigVersionKind.DRAFT,
backend_binding_ref="existing-binding",
)
)
session.execute(
update(Conversation)
.where(Conversation.id == harness.conversation.id)
.values({Conversation.agent_workspace_binding_id: binding_id})
)
response = harness.get("agent")
if same_tenant:
assert response.status_code == HTTPStatus.OK
context = harness.queries.prepare(
app_id=harness.target.id,
app_owner_tenant_id=harness.target.tenant_id,
expected_app_mode=AppMode.AGENT,
actor=MessageAccount(harness.account.id),
invoke_from="debugger",
message_id=harness.message.id,
)
assert context is not None
assert context.config["prompt"] == "Conversation bound questions"
else:
assert response.status_code == HTTPStatus.NOT_FOUND
assert response.get_json()["code"] == "agent_version_not_found_error"
@@ -0,0 +1,390 @@
"""Installed-app suggested questions over real HTTP admission and SQLite services."""
from collections.abc import Callable, Iterator
from dataclasses import dataclass, replace
from decimal import Decimal
from uuid import uuid4
import pytest
from flask import Blueprint, Flask
from redis import Redis
from sqlalchemy import Connection, Engine, delete, event, update
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
from werkzeug.test import TestResponse
from constants import COOKIE_NAME_CSRF_TOKEN, HEADER_NAME_CSRF_TOKEN
from controllers.console.explore.message import MessageSuggestedQuestionApi
from controllers.console.wraps import _is_setup_completed
from enums import DeploymentEdition
from extensions.ext_application_services import build_application_services
from extensions.ext_database import db
from extensions.ext_login import DifyLoginManager, bind_account_loader, load_user_from_request, unauthorized_handler
from extensions.ext_redis import RedisClientWrapper
from graphon.model_runtime.entities.model_entities import ModelType
from libs.datetime_utils import naive_utc_now
from libs.external_api import ExternalApi
from libs.passport import PassportService
from libs.token import generate_csrf_token
from models import Account, Tenant
from models.account import TenantAccountJoin, TenantAccountRole
from models.enums import ConversationFromSource
from models.model import App, AppMode, AppModelConfig, Conversation, DifySetup, InstalledApp, Message
from models.provider import Provider, TenantDefaultModel
from models.workflow import Workflow
from repositories.installed_app_repository import SQLAlchemyInstalledAppRepository
from services.installed_app_access_service import InstalledAppAccessService
from tests.helpers.unit.model_factories import make_account, make_app, make_conversation, make_message
@dataclass(frozen=True)
class _Harness:
app: Flask
account: Account
target: App
installation: InstalledApp
conversation: Conversation
message: Message
factory: sessionmaker[Session]
admission_factory: sessionmaker[Session]
sessions: list[Session]
def get(self, *, installation_id: str | None = None, authenticated: bool = True, csrf: bool = True) -> TestResponse:
client = self.app.test_client()
token = generate_csrf_token(self.account.id)
headers = {HEADER_NAME_CSRF_TOKEN: token} if csrf else {}
if authenticated:
headers["Authorization"] = f"Bearer {PassportService().issue({'user_id': self.account.id})}"
client.set_cookie(COOKIE_NAME_CSRF_TOKEN, token)
return client.get(
f"/installed-apps/{installation_id or self.installation.id}/messages/{self.message.id}/suggested-questions",
headers=headers,
)
def assert_closed(self, *, caller: Session | None = None) -> None:
assert self.sessions
assert all(
not session.in_transaction() and not session.identity_map
for session in self.sessions
if session is not caller
)
@pytest.fixture
def harness(
_empty_model_provider_daemon: None,
config_overrides: Callable[..., None],
sqlite_engine: Engine,
sqlite_session_factory: sessionmaker[Session],
) -> Iterator[_Harness]:
config_overrides(
DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY,
SECRET_KEY="installed-suggested-questions-test-key",
LOGIN_DISABLED=False,
INIT_PASSWORD="",
CONSOLE_API_URL="http://localhost",
CONSOLE_WEB_URL="http://localhost",
COOKIE_DOMAIN="",
)
workspace = Tenant(name="Viewer workspace")
owner = Tenant(name="App owner workspace")
account = make_account(account_id=str(uuid4()))
account.last_active_at = naive_utc_now()
target = make_app(app_id=str(uuid4()), tenant_id=owner.id)
installation = InstalledApp(
tenant_id=workspace.id,
app_id=target.id,
app_owner_tenant_id=owner.id,
position=0,
is_pinned=False,
last_used_at=None,
)
config = AppModelConfig(app_id=target.id, suggested_questions_after_answer='{"enabled":true}')
conversation = make_conversation(
conversation_id=str(uuid4()),
app_id=target.id,
inputs={},
from_source=ConversationFromSource.CONSOLE,
from_account_id=account.id,
)
message = make_message(
message_id=str(uuid4()),
app_id=target.id,
conversation_id=conversation.id,
inputs={},
message={},
query="How?",
answer="Like this.",
message_unit_price=Decimal(0),
answer_unit_price=Decimal(0),
currency="USD",
from_source=ConversationFromSource.CONSOLE,
from_account_id=account.id,
)
with sqlite_session_factory.begin() as session:
session.add_all(
[
workspace,
owner,
account,
target,
installation,
config,
conversation,
message,
TenantAccountJoin(
tenant_id=workspace.id, account_id=account.id, role=TenantAccountRole.OWNER, current=True
),
DifySetup(version="test"),
# Real provider ID validation fails before plugin discovery or model I/O.
Provider(tenant_id=owner.id, provider_name="invalid/provider", is_valid=True),
TenantDefaultModel(
tenant_id=owner.id,
provider_name="invalid/provider",
model_name="invalid-model",
model_type=ModelType.LLM,
),
]
)
session.flush()
target.app_model_config_id = config.id
conversation.app_model_config_id = config.id
factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
admission_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
app = Flask(__name__)
app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False, SQLALCHEMY_DATABASE_URI=str(sqlite_engine.url))
db.init_app(app)
login_manager = DifyLoginManager()
login_manager.request_loader(load_user_from_request)
login_manager.unauthorized_handler(unauthorized_handler)
login_manager.init_app(app)
redis_client = Redis()
redis = RedisClientWrapper()
redis.initialize(redis_client)
services = build_application_services(
database_client=factory,
deployment_edition=DeploymentEdition.COMMUNITY,
initialization_password="",
redis=redis,
)
# Give the installed-app query its own observable factory for stale-admission tests.
access = InstalledAppAccessService(
installed_apps=SQLAlchemyInstalledAppRepository(session_factory=admission_factory),
is_user_allowed=services.webapp_access.is_user_allowed,
get_access_modes=services.webapp_access.batch_get_access_modes,
get_user_permissions=services.webapp_access.batch_get_user_permissions,
)
app.extensions["application_services"] = replace(
services, installed_apps=replace(services.installed_apps, access=access)
)
bind_account_loader(app, services.accounts.identity.load_user)
blueprint = Blueprint("console", __name__)
api = ExternalApi(blueprint)
api.add_resource(
MessageSuggestedQuestionApi,
"/installed-apps/<uuid:installed_app_id>/messages/<uuid:message_id>/suggested-questions",
)
app.register_blueprint(blueprint)
_is_setup_completed.reset_success()
with app.app_context():
assert _is_setup_completed()
db.session.remove()
sessions: list[Session] = []
def track_session(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
sessions.append(session)
factories = (factory, admission_factory, sqlite_session_factory, db.session.session_factory)
for observed in factories:
event.listen(observed, "after_begin", track_session)
yield _Harness(app, account, target, installation, conversation, message, factory, admission_factory, sessions)
for observed in factories:
event.remove(observed, "after_begin", track_session)
assert redis_client.connection_pool._created_connections == 0
_is_setup_completed.reset_success()
redis_client.close()
with app.app_context():
db.session.remove()
db.engine.dispose()
def _error(response: TestResponse, *, status: int, code: str) -> None:
assert response.status_code == status
assert response.headers["Content-Type"] == "application/json"
assert int(response.headers["Content-Length"]) == len(response.data)
body = response.get_json()
assert body["status"] == status
assert body["code"] == code
@pytest.mark.parametrize("mode", [AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT])
def test_owner_provider_resolution_failure_returns_empty_questions(
harness: _Harness, mode: AppMode, caplog: pytest.LogCaptureFixture
) -> None:
assert harness.target.tenant_id != harness.installation.tenant_id
with harness.factory.begin() as session:
app = session.get(App, harness.target.id)
assert app is not None
app.mode = mode
if mode == AppMode.ADVANCED_CHAT:
workflow = Workflow.new(
tenant_id=app.tenant_id,
app_id=app.id,
type="chat",
version="published",
graph="{}",
features='{"suggested_questions_after_answer":{"enabled":true}}',
created_by=harness.account.id,
environment_variables=[],
conversation_variables=[],
rag_pipeline_variables=[],
)
session.add(workflow)
session.flush()
app.workflow_id = workflow.id
response = harness.get()
assert response.status_code == 200
assert response.get_json() == {"data": []}
failures = [
record.exc_info[1]
for record in caplog.records
if record.name == "services.message_suggested_questions_generator" and record.exc_info is not None
]
assert len(failures) == 1
assert isinstance(failures[0], ValueError)
assert str(failures[0]) == "Invalid provider: invalid/provider"
harness.assert_closed()
with harness.factory() as session:
installation = session.get(InstalledApp, harness.installation.id)
assert installation is not None
assert installation.last_used_at is None
@pytest.mark.parametrize("mode", [AppMode.COMPLETION, AppMode.WORKFLOW, AppMode.AGENT])
def test_non_chat_modes_return_specific_error(harness: _Harness, mode: AppMode) -> None:
with harness.factory.begin() as session:
session.execute(update(App).where(App.id == harness.target.id).values(mode=mode))
_error(harness.get(), status=400, code="not_chat_app")
harness.assert_closed()
@pytest.mark.parametrize("missing", ["installation", "workspace", "app"])
def test_missing_or_foreign_installation_is_rejected(harness: _Harness, missing: str) -> None:
installation_id = harness.installation.id
if missing == "installation":
installation_id = str(uuid4())
else:
with harness.factory.begin() as session:
if missing == "workspace":
session.execute(
update(InstalledApp).where(InstalledApp.id == installation_id).values(tenant_id=str(uuid4()))
)
else:
session.execute(delete(App).where(App.id == harness.target.id))
_error(harness.get(installation_id=installation_id), status=404, code="installed_app_not_found")
harness.assert_closed()
@pytest.mark.parametrize("missing", ["login", "csrf"])
def test_real_login_and_csrf_are_required(harness: _Harness, missing: str) -> None:
response = harness.get(authenticated=missing != "login", csrf=missing != "csrf")
_error(response, status=401, code="unauthorized")
assert response.headers["WWW-Authenticate"] == 'Bearer realm="api"'
if missing == "login":
assert harness.sessions == []
else:
harness.assert_closed()
@pytest.mark.parametrize("model", [Message, Conversation])
@pytest.mark.parametrize("field", ["app_id", "from_account_id", "from_source"])
def test_history_requires_complete_account_ownership(
harness: _Harness, model: type[Message] | type[Conversation], field: str
) -> None:
record_id = harness.message.id if model is Message else harness.conversation.id
value = ConversationFromSource.API if field == "from_source" else str(uuid4())
with harness.factory.begin() as session:
session.execute(update(model).where(model.id == record_id).values({field: value}))
_error(harness.get(), status=404, code="message_not_found" if model is Message else "conversation_not_found")
harness.assert_closed()
@pytest.mark.parametrize("resource", ["message", "conversation", "disabled", "config"])
def test_missing_resources_and_feature_policy_keep_specific_errors(harness: _Harness, resource: str) -> None:
with harness.factory.begin() as session:
if resource == "message":
session.execute(delete(Message).where(Message.id == harness.message.id))
elif resource == "conversation":
session.execute(delete(Conversation).where(Conversation.id == harness.conversation.id))
elif resource == "disabled":
session.execute(
update(AppModelConfig)
.where(AppModelConfig.app_id == harness.target.id)
.values(suggested_questions_after_answer='{"enabled":false}')
)
else:
session.execute(delete(AppModelConfig).where(AppModelConfig.app_id == harness.target.id))
status, code = {
"message": (404, "message_not_found"),
"conversation": (404, "conversation_not_found"),
"disabled": (403, "app_suggested_questions_after_answer_disabled"),
"config": (500, "internal_server_error"),
}[resource]
_error(harness.get(), status=status, code=code)
harness.assert_closed()
@pytest.mark.parametrize("change", ["mode", "owner", "app", "account"])
def test_stale_admitted_resource_is_revalidated(harness: _Harness, change: str) -> None:
def change_resource(_session: Session, _transaction: SessionTransaction) -> None:
with harness.factory.begin() as session:
if change == "account":
session.execute(delete(Account).where(Account.id == harness.account.id))
elif change == "app":
session.execute(delete(App).where(App.id == harness.target.id))
else:
values = {"mode": AppMode.COMPLETION} if change == "mode" else {"tenant_id": str(uuid4())}
session.execute(update(App).where(App.id == harness.target.id).values(values))
event.listen(harness.admission_factory, "after_transaction_end", change_resource, once=True)
try:
response = harness.get()
finally:
event.remove(harness.admission_factory, "after_transaction_end", change_resource)
_error(
response,
status=401 if change == "account" else 400,
code="unauthorized" if change == "account" else "app_unavailable",
)
if change == "account":
assert response.get_json()["message"] == "Account no longer exists."
harness.assert_closed()
def test_removed_installation_takes_effect_on_next_request(harness: _Harness) -> None:
def remove_installation(_session: Session, _transaction: SessionTransaction) -> None:
with harness.factory.begin() as session:
session.execute(delete(InstalledApp).where(InstalledApp.id == harness.installation.id))
event.listen(harness.admission_factory, "after_transaction_end", remove_installation, once=True)
try:
response = harness.get()
finally:
event.remove(harness.admission_factory, "after_transaction_end", remove_installation)
assert response.status_code == 200
assert response.get_json() == {"data": []}
_error(harness.get(), status=404, code="installed_app_not_found")
harness.assert_closed()
def test_malformed_config_has_opaque_error(harness: _Harness) -> None:
with harness.factory.begin() as session:
session.execute(
update(AppModelConfig)
.where(AppModelConfig.app_id == harness.target.id)
.values(suggested_questions_after_answer="private config diagnostic")
)
response = harness.get()
_error(response, status=500, code="internal_server_error")
assert "private config diagnostic" not in response.get_data(as_text=True)
harness.assert_closed()
@@ -0,0 +1,432 @@
"""Trial suggested questions through HTTP, real ownership queries, and SQLite."""
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from decimal import Decimal
from uuid import uuid4
import pytest
from flask import Flask
from sqlalchemy import Connection, Engine, delete, event, select, update
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
from werkzeug.test import TestResponse
import controllers.console.explore.trial as trial_module
import controllers.console.wraps as console_wraps
import libs.login as login_module
from constants import COOKIE_NAME_CSRF_TOKEN, HEADER_NAME_CSRF_TOKEN
from core.memory.token_buffer_memory import PreparedHistory
from enums import DeploymentEdition
from extensions.ext_database import db
from extensions.ext_login import DifyLoginManager, unauthorized_handler
from extensions.ext_redis import RedisClientWrapper
from graphon.model_runtime.entities.model_entities import ModelType
from libs.external_api import ExternalApi
from libs.token import generate_csrf_token
from models import Account, AccountTrialAppRecord, App, AppMode, Conversation, Message, Tenant, TrialApp
from models.account import AccountStatus
from models.agent import Agent, AgentScope, AgentSource, AgentStatus
from models.enums import ConversationFromSource
from models.model import AppModelConfig, DifySetup
from models.provider import Provider, TenantDefaultModel
from repositories.message_repository import MessageRepository
from repositories.recommended_app_catalog_repository import DatabaseRecommendedAppCatalogRepository
from repositories.trial_app_repository import TrialAppRepository
from services.message_suggested_questions_generator import SuggestedQuestionsGenerator
from services.message_suggested_questions_queries import SuggestedQuestionsQuery
from services.message_suggested_questions_service import (
MessageSuggestedQuestionsService,
)
from services.recommended_app_query_service import RecommendedAppQueryService
from services.trial_app_access_service import TrialAppAccessService
@dataclass(frozen=True)
class _TrialAppServices:
access: TrialAppAccessService
@dataclass(frozen=True)
class _ApplicationServices:
trial_apps: _TrialAppServices
recommended_app_queries: RecommendedAppQueryService
message_suggested_questions: MessageSuggestedQuestionsService[PreparedHistory]
@dataclass(frozen=True)
class _Harness:
app: Flask
account: Account
target: App
trial: TrialApp
conversation: Conversation
message: Message
config: AppModelConfig
factory: sessionmaker[Session]
features: RecommendedAppQueryService
read_sessions: list[Session]
legacy_sessions: list[Session]
def get(self, *, app_id: str | None = None, message_id: str | None = None) -> TestResponse:
client = self.app.test_client()
csrf = generate_csrf_token(self.account.id)
client.set_cookie(COOKIE_NAME_CSRF_TOKEN, csrf)
return client.get(
f"/trial-apps/{app_id or self.target.id}/messages/{message_id or self.message.id}/suggested-questions",
headers={HEADER_NAME_CSRF_TOKEN: csrf},
)
def assert_closed(self) -> None:
assert all(
not session.in_transaction() and not session.identity_map
for session in self.read_sessions + self.legacy_sessions
)
def usage(self) -> int | None:
with self.factory() as session:
return session.scalar(
select(AccountTrialAppRecord.count).where(
AccountTrialAppRecord.app_id == self.target.id,
AccountTrialAppRecord.account_id == self.account.id,
)
)
@pytest.fixture
def harness(
_empty_model_provider_daemon: None,
monkeypatch: pytest.MonkeyPatch,
config_overrides: Callable[..., None],
sqlite_session_factory: sessionmaker[Session],
sqlite_engine: Engine,
) -> Iterator[_Harness]:
config_overrides(
DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY,
INIT_PASSWORD="",
LOGIN_DISABLED=False,
RBAC_ENABLED=False,
SECRET_KEY="trial-suggested-question-test-secret",
CONSOLE_WEB_URL="http://localhost",
CONSOLE_API_URL="http://localhost",
COOKIE_DOMAIN="",
)
console_wraps._is_setup_completed.reset_success()
account = Account(name="Trial viewer", email="trial@example.com")
account._current_tenant = Tenant(name="Viewer workspace")
target = App(tenant_id=str(uuid4()), name="Trial app", mode=AppMode.CHAT, enable_site=True, enable_api=False)
target.id = str(uuid4())
trial = TrialApp(app_id=target.id, tenant_id=str(uuid4()), trial_limit=3)
config = AppModelConfig(
app_id=target.id,
suggested_questions_after_answer='{"enabled":true,"prompt":"Suggest concise follow-ups"}',
)
with sqlite_session_factory.begin() as session:
session.add_all(
[
account,
target,
trial,
config,
DifySetup(version="test"),
Provider(tenant_id=target.tenant_id, provider_name="invalid/provider", is_valid=True),
TenantDefaultModel(
tenant_id=target.tenant_id,
provider_name="invalid/provider",
model_name="invalid-model",
model_type=ModelType.LLM,
),
]
)
session.flush()
target.app_model_config_id = config.id
conversation = Conversation(
app_id=target.id,
app_model_config_id=config.id,
mode=AppMode.CHAT,
name="Trial conversation",
_inputs={},
from_source=ConversationFromSource.CONSOLE,
from_account_id=account.id,
)
session.add(conversation)
session.flush()
message = Message(
app_id=target.id,
conversation_id=conversation.id,
_inputs={},
query="What is a trial?",
message={},
message_unit_price=Decimal(0),
answer="A way to try an app.",
answer_unit_price=Decimal(0),
currency="USD",
from_source=ConversationFromSource.CONSOLE,
from_account_id=account.id,
)
session.add(message)
read_sessions: list[Session] = []
read_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
@event.listens_for(read_factory, "after_begin")
def track_read(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
read_sessions.append(session)
trial_apps = TrialAppRepository(session_factory=read_factory)
features = RecommendedAppQueryService(
catalog=DatabaseRecommendedAppCatalogRepository(read_factory, redis=RedisClientWrapper()),
trial_apps=trial_apps,
trial_enabled=True,
)
queries = SuggestedQuestionsQuery(session_factory=read_factory, repository_factory=MessageRepository)
services = _ApplicationServices(
trial_apps=_TrialAppServices(access=TrialAppAccessService(apps=trial_apps)),
recommended_app_queries=features,
message_suggested_questions=MessageSuggestedQuestionsService(
queries=queries, generator=SuggestedQuestionsGenerator()
),
)
monkeypatch.setattr(login_module, "current_user", account)
app = Flask(__name__)
app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False, SQLALCHEMY_DATABASE_URI=str(sqlite_engine.url))
db.init_app(app)
app.extensions["application_services"] = services
legacy_sessions: list[Session] = []
def track_legacy(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
legacy_sessions.append(session)
event.listen(db.session.session_factory, "after_begin", track_legacy)
login_manager = DifyLoginManager()
login_manager.init_app(app)
login_manager.unauthorized_handler(unauthorized_handler)
api = ExternalApi(app)
api.add_resource(
trial_module.TrialMessageSuggestedQuestionApi,
"/trial-apps/<uuid:app_id>/messages/<uuid:message_id>/suggested-questions",
)
yield _Harness(
app,
account,
target,
trial,
conversation,
message,
config,
sqlite_session_factory,
features,
read_sessions,
legacy_sessions,
)
event.remove(db.session.session_factory, "after_begin", track_legacy)
console_wraps._is_setup_completed.reset_success()
with app.app_context():
db.session.remove()
db.engine.dispose()
def _assert_error(response: TestResponse, status: int, code: str, message: str | None = None) -> None:
assert response.status_code == status, response.get_json()
body = response.get_json()
assert body["code"] == code
assert body["status"] == status
assert body["message"]
if message is not None:
assert body["message"] == message
assert response.headers["Content-Type"] == "application/json"
@pytest.mark.parametrize("mode", [AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.AGENT])
def test_model_resolution_failure_keeps_empty_response_and_usage(
harness: _Harness, mode: AppMode, caplog: pytest.LogCaptureFixture
) -> None:
with harness.factory.begin() as session:
session.execute(update(App).where(App.id == harness.target.id).values(mode=mode))
session.execute(update(Conversation).where(Conversation.id == harness.conversation.id).values(mode=mode))
session.add_all(
[
AccountTrialAppRecord(app_id=harness.target.id, account_id=harness.account.id, count=1),
AccountTrialAppRecord(app_id=harness.target.id, account_id=str(uuid4()), count=3),
]
)
response = harness.get()
assert response.status_code == 200, response.get_json()
assert response.get_json() == {"data": []}
assert response.headers["Content-Type"] == "application/json"
assert int(response.headers["Content-Length"]) == len(response.data)
assert any(
record.exc_info
and isinstance(record.exc_info[1], ValueError)
and "Invalid provider: invalid/provider" in str(record.exc_info[1])
for record in caplog.records
)
assert harness.target.tenant_id not in {harness.trial.tenant_id, harness.account.current_tenant_id}
assert harness.usage() == 1
harness.assert_closed()
def test_missing_published_agent_version_has_specific_error(harness: _Harness) -> None:
with harness.factory.begin() as session:
session.execute(update(App).where(App.id == harness.target.id).values(mode=AppMode.AGENT))
session.add(
Agent(
tenant_id=harness.target.tenant_id,
app_id=harness.target.id,
name="Trial Agent",
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
status=AgentStatus.ACTIVE,
)
)
_assert_error(harness.get(), 404, "agent_version_not_found_error", "Agent config version not found.")
assert harness.usage() is None
harness.assert_closed()
@pytest.mark.parametrize("entity", ["message", "conversation"])
@pytest.mark.parametrize("mismatch", ["missing", "app", "account", "source", "end-user"])
def test_message_and_conversation_require_complete_owner_chain(harness: _Harness, entity: str, mismatch: str) -> None:
message_id = harness.message.id
with harness.factory.begin() as session:
record = (
session.get(Message, harness.message.id)
if entity == "message"
else session.get(Conversation, harness.conversation.id)
)
assert record is not None
match mismatch:
case "missing":
if entity == "message":
message_id = str(uuid4())
else:
session.delete(record)
case "app":
record.app_id = str(uuid4())
case "account":
record.from_account_id = str(uuid4())
case "source":
record.from_source = ConversationFromSource.API
case "end-user":
record.from_end_user_id = str(uuid4())
response = harness.get(message_id=message_id)
_assert_error(response, 404, "not_found", "Message not found" if entity == "message" else "Conversation not found")
assert harness.usage() is None
harness.assert_closed()
def test_deleted_conversation_is_not_visible(harness: _Harness) -> None:
with harness.factory.begin() as session:
session.execute(update(Conversation).where(Conversation.id == harness.conversation.id).values(is_deleted=True))
_assert_error(harness.get(), 404, "not_found", "Conversation not found")
harness.assert_closed()
@pytest.mark.parametrize("config", [None, '{"enabled":false}'])
def test_disabled_or_unset_suggestions_have_specific_error(harness: _Harness, config: str | None) -> None:
with harness.factory.begin() as session:
session.execute(
update(AppModelConfig)
.where(AppModelConfig.id == harness.config.id)
.values(suggested_questions_after_answer=config)
)
_assert_error(harness.get(), 403, "app_suggested_questions_after_answer_disabled")
harness.assert_closed()
@pytest.mark.parametrize("mode", [AppMode.COMPLETION, AppMode.WORKFLOW])
def test_unsupported_app_modes_fail_before_runtime(harness: _Harness, mode: AppMode) -> None:
with harness.factory.begin() as session:
session.execute(update(App).where(App.id == harness.target.id).values(mode=mode))
_assert_error(harness.get(), 400, "not_chat_app")
assert len(harness.read_sessions) == 1
harness.assert_closed()
@pytest.mark.parametrize(
("denial", "status", "code"),
[
("setup", 401, "not_setup"),
("feature", 403, "trial_app_feature_disabled"),
("missing_trial", 403, "trial_app_not_allowed"),
("missing_app", 403, "trial_app_not_allowed"),
("quota", 403, "trial_app_limit_exceeded"),
("uninitialized", 400, "account_not_initialized"),
("unauthenticated", 401, "unauthorized"),
],
)
def test_admission_blocks_before_runtime(
harness: _Harness, monkeypatch: pytest.MonkeyPatch, denial: str, status: int, code: str
) -> None:
if denial == "setup":
with harness.factory.begin() as session:
session.execute(delete(DifySetup))
console_wraps._is_setup_completed.reset_success()
elif denial == "feature":
harness.features._trial_enabled = False
elif denial == "uninitialized":
harness.account.status = AccountStatus.UNINITIALIZED
elif denial == "unauthenticated":
monkeypatch.setattr(login_module, "current_user", None)
else:
with harness.factory.begin() as session:
if denial == "missing_trial":
session.execute(delete(TrialApp).where(TrialApp.app_id == harness.target.id))
elif denial == "missing_app":
session.execute(delete(App).where(App.id == harness.target.id))
else:
session.add(AccountTrialAppRecord(app_id=harness.target.id, account_id=harness.account.id, count=3))
response = harness.get()
if denial == "unauthenticated":
assert response.status_code == 401
assert response.get_json() == {"code": "unauthorized", "message": "Unauthorized.", "status": 401}
else:
_assert_error(response, status, code)
harness.assert_closed()
def test_model_resolution_preserves_outer_request_session(harness: _Harness) -> None:
with harness.app.app_context():
# Bootstrap uses the scoped session; complete that real query before the caller edits its entity.
assert console_wraps._is_setup_completed()
db.session.remove()
outer_session = db.session()
app_model = outer_session.get(App, harness.target.id)
assert app_model is not None
app_model.name = "Pending caller change"
response = harness.get()
assert response.status_code == 200
assert db.session() is outer_session
assert outer_session.in_transaction()
assert app_model in outer_session.dirty
assert app_model.name == "Pending caller change"
harness.assert_closed()
with harness.factory() as session:
saved_app = session.get(App, harness.target.id)
assert saved_app is not None
assert saved_app.name == "Trial app"
def test_advanced_chat_without_published_workflow_keeps_empty_success(harness: _Harness) -> None:
with harness.factory.begin() as session:
session.execute(update(App).where(App.id == harness.target.id).values(mode=AppMode.ADVANCED_CHAT))
response = harness.get()
assert response.status_code == 200
assert response.get_json() == {"data": []}
assert len(harness.read_sessions) == 2
assert harness.usage() is None
harness.assert_closed()
def test_deleted_account_has_explicit_unauthorized_response(harness: _Harness) -> None:
with harness.factory.begin() as session:
session.execute(delete(Account).where(Account.id == harness.account.id))
response = harness.get()
_assert_error(response, 401, "unauthorized", "Account no longer exists.")
assert response.headers["WWW-Authenticate"] == 'Bearer realm="api"'
assert harness.usage() is None
harness.assert_closed()
@@ -0,0 +1,751 @@
"""Service API suggested questions with real admission, end-user provisioning, and SQLite."""
from collections.abc import Iterator
from dataclasses import FrozenInstanceError, asdict, dataclass
from decimal import Decimal
from typing import Literal
from uuid import uuid4
import pytest
from flask import Flask
from flask_login import LoginManager, current_user
from sqlalchemy import Connection, Engine, delete, event, select, text
from sqlalchemy.orm import Session, SessionTransaction, object_session, sessionmaker
from werkzeug.test import TestResponse
from constants.resource_access_token import ResourceAccessTokenResourceType
from controllers.service_api.app.message import MessageSuggestedApi
from controllers.service_api.flask_admission import service_api_end_user_admission
from controllers.service_api.wraps import FetchUserArg, WhereisUserArg, validate_app_token
from extensions.application_services.resource_access_token import build_resource_access_token_service
from extensions.ext_database import db
from graphon.model_runtime.entities.model_entities import ModelType
from libs.external_api import ExternalApi
from machinery.context import RequestContext, ServiceApiEndUserContext
from models import Tenant, TenantStatus
from models.agent import Agent, AgentScope, AgentSource
from models.enums import ApiTokenType, ConversationFromSource, EndUserType
from models.model import ApiToken, App, AppMode, AppModelConfig, Conversation, EndUser, Message
from models.provider import Provider, TenantDefaultModel
from models.resource_access_token import ResourceAccessToken, ResourceAccessTokenRelation
from models.workflow import Workflow, WorkflowType
from repositories.app_definition_query_repository import AppDefinitionQueryRepository
from repositories.app_scoped_end_user_repository import AppScopedEndUserRepo
from repositories.message_repository import MessageRepository
from services.app_definition_query_service import AppDefinitionQueryService, AppDefinitionUnavailableError
from services.app_scoped_end_user_service import AppScopedEndUserService
from services.auth.resource_access_token_contracts import ResourceAccessTokenCreateResult, ResourceAccessTokenResource
from services.errors.app import AppAbnormalStatusError, AppApiDisabledError
from services.errors.workspace import WorkspaceArchivedError, WorkspaceNotFoundError
from services.message_suggested_questions_generator import SuggestedQuestionsGenerator
from services.message_suggested_questions_queries import SuggestedQuestionsQuery
from services.message_suggested_questions_service import (
MessageSuggestedQuestions,
MessageSuggestedQuestionsService,
)
from services.resource_access_token_service import ResourceAccessTokenService
from tests.helpers.unit.model_factories import make_app, make_conversation, make_end_user, make_message
@dataclass(frozen=True)
class _EndUsers:
commands: AppScopedEndUserService[EndUser]
@dataclass(frozen=True)
class _Services:
app_definitions: AppDefinitionQueryService
message_suggested_questions: MessageSuggestedQuestions
app_scoped_end_users: _EndUsers
resource_access_tokens: ResourceAccessTokenService
@dataclass(frozen=True)
class _Harness:
app: Flask
target: App
tenant: Tenant
end_user: EndUser
conversation: Conversation
config: AppModelConfig
provision_factory: sessionmaker[Session]
factory: sessionmaker[Session]
message_id: str
admissions: list[Session]
provisions: list[Session]
queries: list[Session]
scoped_sessions: list[Session]
def get(
self,
*,
user: str | None = "alice",
authorization: str | None = "Bearer test-token",
requested_app_id: str | None = None,
) -> TestResponse:
headers: dict[str, str] = {"Authorization": authorization} if authorization is not None else {}
if requested_app_id is not None:
headers["X-Dify-App-ID"] = requested_app_id
query: dict[str, str] = {"user": user} if user is not None else {}
return self.app.test_client().get(f"/messages/{self.message_id}/suggested", query_string=query, headers=headers)
def assert_closed(self, *, caller: Session | None = None) -> None:
assert all(
not session.in_transaction() and not session.identity_map
for session in self.admissions + self.provisions + self.queries + self.scoped_sessions
if session is not caller
)
@pytest.fixture
def harness(
_empty_model_provider_daemon: None,
sqlite_engine: Engine,
sqlite_session_factory: sessionmaker[Session],
) -> Iterator[_Harness]:
tenant = Tenant(name="API workspace")
target = make_app(app_id=str(uuid4()), tenant_id=tenant.id)
end_user = make_end_user(
end_user_id=str(uuid4()),
tenant_id=tenant.id,
app_id=target.id,
end_user_type=EndUserType.SERVICE_API,
session_id="alice",
external_user_id="alice",
)
config = AppModelConfig(app_id=target.id, suggested_questions_after_answer='{"enabled": true}')
config.id = str(uuid4())
target.app_model_config_id = config.id
conversation = make_conversation(
conversation_id=str(uuid4()),
app_id=target.id,
inputs={},
from_source=ConversationFromSource.API,
from_end_user_id=end_user.id,
)
conversation.app_model_config_id = config.id
message = make_message(
message_id=str(uuid4()),
app_id=target.id,
conversation_id=conversation.id,
inputs={},
query="How does this work?",
message={},
answer="Like this.",
message_unit_price=Decimal(0),
answer_unit_price=Decimal(0),
currency="USD",
from_source=ConversationFromSource.API,
from_end_user_id=end_user.id,
)
token = ApiToken(app_id=target.id, tenant_id=tenant.id, type=ApiTokenType.APP, token="test-token")
with sqlite_session_factory.begin() as session:
# Fail the actual provider ID validation before plugin discovery can run.
provider = Provider(tenant_id=tenant.id, provider_name="invalid/provider", is_valid=True)
session.add(
TenantDefaultModel(
tenant_id=tenant.id,
provider_name="invalid/provider",
model_name="invalid-model",
model_type=ModelType.LLM,
)
)
session.add_all([tenant, target, end_user, config, conversation, message, token, provider])
app = Flask(__name__)
app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False, SQLALCHEMY_DATABASE_URI=str(sqlite_engine.url))
db.init_app(app)
LoginManager(app)
admissions: list[Session] = []
provisions: list[Session] = []
query_sessions: list[Session] = []
scoped_sessions: list[Session] = []
admission_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
provision_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
query_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
def track_admission(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
admissions.append(session)
def track_provision(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
# End-user lookup/creation must not overlap the admission transaction.
assert all(not admission.in_transaction() and not admission.identity_map for admission in admissions)
provisions.append(session)
def track_query(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
query_sessions.append(session)
def track_scoped(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
scoped_sessions.append(session)
event.listen(admission_factory, "after_begin", track_admission)
event.listen(provision_factory, "after_begin", track_provision)
event.listen(query_factory, "after_begin", track_query)
event.listen(db.session.session_factory, "after_begin", track_scoped)
queries = SuggestedQuestionsQuery(session_factory=query_factory, repository_factory=MessageRepository)
service = MessageSuggestedQuestionsService(queries=queries, generator=SuggestedQuestionsGenerator())
app.extensions["application_services"] = _Services(
app_definitions=AppDefinitionQueryService(
definitions=AppDefinitionQueryRepository(session_factory=admission_factory), builtin_icon_url_prefix=""
),
message_suggested_questions=service,
app_scoped_end_users=_EndUsers(
AppScopedEndUserService(end_users=AppScopedEndUserRepo(session_factory=provision_factory))
),
resource_access_tokens=build_resource_access_token_service(database_client=admission_factory),
)
api = ExternalApi(app)
api.add_resource(MessageSuggestedApi, "/messages/<uuid:message_id>/suggested")
yield _Harness(
app,
target,
tenant,
end_user,
conversation,
config,
provision_factory,
sqlite_session_factory,
message.id,
admissions,
provisions,
query_sessions,
scoped_sessions,
)
event.remove(admission_factory, "after_begin", track_admission)
event.remove(provision_factory, "after_begin", track_provision)
event.remove(query_factory, "after_begin", track_query)
event.remove(db.session.session_factory, "after_begin", track_scoped)
with app.app_context():
db.session.remove()
db.engine.dispose()
@pytest.mark.parametrize("mode", [AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
def test_admitted_identity_and_mode_return_empty_questions_for_invalid_provider_config(
harness: _Harness, mode: AppMode, caplog: pytest.LogCaptureFixture
) -> None:
with harness.factory.begin() as session:
target = session.get(App, harness.target.id)
assert target is not None
target.mode = mode
if mode == AppMode.ADVANCED_CHAT:
workflow = Workflow(
tenant_id=harness.tenant.id,
app_id=target.id,
type=WorkflowType.CHAT,
version="published",
graph='{"nodes":[],"edges":[]}',
features='{"suggested_questions_after_answer":{"enabled":true}}',
created_by=str(uuid4()),
)
session.add(workflow)
session.flush()
target.workflow_id = workflow.id
response = harness.get()
assert response.status_code == 200
assert response.json == {"result": "success", "data": []}
assert response.headers["Content-Type"] == "application/json"
failures = [
record.exc_info[1]
for record in caplog.records
if record.name == "services.message_suggested_questions_generator" and record.exc_info is not None
]
assert len(failures) == 1
assert isinstance(failures[0], ValueError)
assert str(failures[0]) == "Invalid provider: invalid/provider"
assert len(harness.queries) == 1
with harness.factory() as session:
end_user = session.scalar(select(EndUser).where(EndUser.app_id == harness.target.id))
assert end_user is not None
assert end_user.type == EndUserType.SERVICE_API
assert end_user.external_user_id == "alice"
assert end_user.id == harness.end_user.id
harness.assert_closed()
def test_existing_user_is_reused_within_the_token_app_only(harness: _Harness) -> None:
decoy = make_end_user(
end_user_id=str(uuid4()),
tenant_id=str(uuid4()),
app_id=str(uuid4()),
end_user_type=EndUserType.SERVICE_API,
session_id="alice",
external_user_id="alice",
)
with harness.factory.begin() as session:
session.add(decoy)
assert harness.get().status_code == 200
with harness.factory() as session:
assert len(session.scalars(select(EndUser)).all()) == 2
@pytest.mark.parametrize("authorization", [None, "Basic test-token"])
def test_invalid_authorization_stops_before_database_admission(harness: _Harness, authorization: str | None) -> None:
response = harness.get(authorization=authorization)
assert response.status_code == 401
assert response.json is not None
assert response.json["code"] == "unauthorized"
assert response.headers["WWW-Authenticate"] == 'Bearer realm="api"'
assert harness.admissions == harness.provisions == harness.queries == []
@pytest.mark.parametrize("user", [None, ""])
def test_required_user_rejected_before_provisioning(harness: _Harness, user: str | None) -> None:
response = harness.get(user=user)
assert response.status_code == 400
assert response.json is not None
assert response.json["code"] == "invalid_param"
assert response.json["message"] == "Arg user must be provided."
assert harness.provisions == harness.queries == []
harness.assert_closed()
@pytest.mark.parametrize(
("failure", "domain_error", "code", "message"),
[
("missing_app", AppDefinitionUnavailableError, "app_not_found", "The app no longer exists."),
("null_app", AppDefinitionUnavailableError, "app_not_found", "The app no longer exists."),
("abnormal_app", AppAbnormalStatusError, "app_abnormal_status", "The app's status is abnormal."),
("empty_app_status", AppAbnormalStatusError, "app_abnormal_status", "The app's status is abnormal."),
("disabled_api", AppApiDisabledError, "app_api_disabled", "The app's API service has been disabled."),
("archived_tenant", WorkspaceArchivedError, "workspace_archived", "The workspace's status is archived."),
("missing_tenant", WorkspaceNotFoundError, "workspace_not_found", "Tenant does not exist."),
],
)
def test_app_and_workspace_admission(
harness: _Harness,
failure: Literal[
"missing_app",
"null_app",
"abnormal_app",
"empty_app_status",
"disabled_api",
"archived_tenant",
"missing_tenant",
],
domain_error: type[Exception],
code: str,
message: str,
) -> None:
with harness.factory.begin() as session:
target = session.get(App, harness.target.id)
tenant = session.get(Tenant, harness.tenant.id)
assert target is not None
assert tenant is not None
match failure:
case "missing_app":
session.add(make_app(app_id=str(uuid4()), tenant_id=tenant.id))
session.delete(target)
case "null_app":
token = session.scalar(select(ApiToken).where(ApiToken.token == "test-token"))
assert token is not None
token.app_id = None
case "abnormal_app" | "empty_app_status":
# Bypass EnumText validation to exercise malformed persisted status values.
session.execute(
text("UPDATE apps SET status = :status WHERE id = :app_id"),
{"status": "disabled" if failure == "abnormal_app" else "", "app_id": target.id},
)
case "disabled_api":
target.enable_api = False
case "archived_tenant":
tenant.status = TenantStatus.ARCHIVE
case "missing_tenant":
session.delete(tenant)
services = harness.app.extensions["application_services"]
assert isinstance(services, _Services)
with pytest.raises(domain_error):
services.app_definitions.get_service_api_app(None if failure == "null_app" else harness.target.id)
harness.assert_closed()
response = harness.get(user="new-user")
assert response.status_code == 403
assert response.json == {"code": code, "message": message, "status": 403}
assert harness.provisions == harness.queries == []
with harness.factory() as session:
assert session.scalars(select(EndUser.id)).all() == [harness.end_user.id]
harness.assert_closed()
@pytest.mark.parametrize("mode", [AppMode.COMPLETION, AppMode.WORKFLOW])
def test_unsupported_mode_does_not_invoke_questions(harness: _Harness, mode: AppMode) -> None:
with harness.factory.begin() as session:
target = session.get(App, harness.target.id)
assert target is not None
target.mode = mode
response = harness.get()
assert response.status_code == 400
assert response.json is not None
assert response.json["code"] == "not_chat_app"
assert harness.queries == []
harness.assert_closed()
@pytest.mark.parametrize(
("failure", "status", "code", "message"),
[
("message", 404, "not_found", "Message Not Exists."),
("conversation", 404, "not_found", "Conversation not found"),
("agent_version", 404, "agent_version_not_found_error", "Agent config version not found."),
("disabled", 403, "app_suggested_questions_after_answer_disabled", ""),
],
)
def test_domain_errors_have_specific_http_codes(
harness: _Harness,
failure: Literal["message", "conversation", "agent_version", "disabled"],
status: int,
code: str,
message: str,
) -> None:
with harness.factory.begin() as session:
match failure:
case "message":
message_record = session.get(Message, harness.message_id)
assert message_record is not None
session.delete(message_record)
case "conversation":
conversation = session.get(Conversation, harness.conversation.id)
assert conversation is not None
session.delete(conversation)
case "disabled":
config = session.get(AppModelConfig, harness.config.id)
assert config is not None
config.suggested_questions_after_answer = '{"enabled":false}'
case "agent_version":
target = session.get(App, harness.target.id)
assert target is not None
target.mode = AppMode.AGENT
session.add(
Agent(
tenant_id=harness.tenant.id,
app_id=target.id,
name="Missing published version",
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
active_config_snapshot_id=str(uuid4()),
)
)
response = harness.get()
assert response.status_code == status
assert response.headers["Content-Type"] == "application/json"
assert response.json is not None
assert response.json["code"] == code
assert response.json["status"] == status
if message:
assert response.json["message"] == message
harness.assert_closed()
@pytest.mark.parametrize(
("change", "status", "code", "message"),
[
("app_mode", 400, "app_unavailable", ""),
("end_user_tenant", 404, "not_found", "End user not found"),
],
)
def test_scope_changes_after_admission_have_specific_http_codes(
harness: _Harness,
change: Literal["app_mode", "end_user_tenant"],
status: int,
code: str,
message: str,
) -> None:
def change_scope(_session: Session, _transaction: SessionTransaction) -> None:
with harness.factory.begin() as session:
if change == "app_mode":
target = session.get(App, harness.target.id)
assert target is not None
target.mode = AppMode.ADVANCED_CHAT
else:
end_user = session.get(EndUser, harness.end_user.id)
assert end_user is not None
end_user.tenant_id = str(uuid4())
event.listen(harness.provision_factory, "after_transaction_end", change_scope, once=True)
try:
response = harness.get()
finally:
event.remove(harness.provision_factory, "after_transaction_end", change_scope)
assert response.status_code == status
assert response.headers["Content-Type"] == "application/json"
assert response.json is not None
assert response.json["code"] == code
assert response.json["status"] == status
if message:
assert response.json["message"] == message
harness.assert_closed()
def test_malformed_configuration_returns_an_opaque_internal_error(harness: _Harness) -> None:
with harness.factory.begin() as session:
config = session.get(AppModelConfig, harness.config.id)
assert config is not None
config.suggested_questions_after_answer = "private diagnostic: invalid JSON"
response = harness.get()
assert response.status_code == 500
assert response.headers["Content-Type"] == "application/json"
assert response.json is not None
assert response.json["code"] == "internal_server_error"
assert response.json["status"] == 500
assert "private diagnostic" not in response.get_data(as_text=True)
harness.assert_closed()
def test_caller_session_and_pending_edits_are_preserved(harness: _Harness) -> None:
with harness.app.app_context():
caller = db.session()
target = caller.get(App, harness.target.id)
assert target is not None
target.name = "Pending caller change"
transaction = caller.get_transaction()
response = harness.get()
assert response.status_code == 200
assert response.json == {"result": "success", "data": []}
assert db.session() is caller
assert caller.get_transaction() is transaction
assert target in caller.dirty
assert target.name == "Pending caller change"
harness.assert_closed(caller=caller)
caller.rollback()
with harness.factory() as session:
target = session.get(App, harness.target.id)
assert target is not None
assert target.name == harness.target.name
def test_default_admission_keeps_legacy_app_attached(harness: _Harness) -> None:
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY, required=True))
def view(*, app_model: App, end_user: EndUser) -> str:
assert object_session(app_model) is db.session()
assert app_model.id == harness.target.id
assert end_user.app_id == app_model.id
return "legacy"
harness.app.add_url_rule("/legacy", view_func=view)
with harness.app.test_request_context("/legacy?user=alice", headers={"Authorization": "Bearer test-token"}):
assert harness.app.dispatch_request() == "legacy"
assert db.session().in_transaction()
assert harness.admissions == []
def test_request_identity_remains_installed_after_generation(harness: _Harness) -> None:
with harness.app.test_client() as client:
response = client.get(
f"/messages/{harness.message_id}/suggested?user=alice", headers={"Authorization": "Bearer test-token"}
)
assert response.status_code == 200
assert response.json == {"result": "success", "data": []}
assert current_user.id == harness.end_user.id
assert current_user.app_id == harness.target.id
assert current_user.tenant_id == harness.tenant.id
@pytest.mark.parametrize("fetch_from", [WhereisUserArg.QUERY, WhereisUserArg.JSON, WhereisUserArg.FORM])
def test_admission_injects_immutable_scope_and_provisions_request_user(
harness: _Harness,
fetch_from: WhereisUserArg,
) -> None:
class AdmittedView:
@service_api_end_user_admission(fetch_user_arg=FetchUserArg(fetch_from=fetch_from, required=True))
def get(self, context: ServiceApiEndUserContext) -> dict[str, str]:
assert isinstance(context, ServiceApiEndUserContext)
harness.assert_closed()
assert current_user.id == context.end_user_id
payload = asdict(context)
for field_name in payload:
with pytest.raises(FrozenInstanceError):
setattr(context, field_name, "untrusted")
return payload
harness.app.add_url_rule("/admitted", view_func=AdmittedView().get, methods=["GET", "POST"])
client = harness.app.test_client()
headers = {"Authorization": "Bearer test-token"}
match fetch_from:
case WhereisUserArg.QUERY:
response = client.get("/admitted?user=bob", headers=headers)
case WhereisUserArg.JSON:
response = client.post("/admitted", json={"user": "bob"}, headers=headers)
case WhereisUserArg.FORM:
response = client.post("/admitted", data={"user": "bob"}, headers=headers)
assert response.status_code == 200
with harness.factory() as session:
end_user = session.scalar(
select(EndUser).where(EndUser.app_id == harness.target.id, EndUser.external_user_id == "bob")
)
assert end_user is not None
assert end_user.session_id == "bob"
assert response.json == {
"app_id": harness.target.id,
"tenant_id": harness.tenant.id,
"app_mode": harness.target.mode,
"end_user_id": end_user.id,
}
def _create_resource_token(harness: _Harness, *, app_ids: tuple[str, ...]) -> ResourceAccessTokenCreateResult:
services = harness.app.extensions["application_services"]
assert isinstance(services, _Services)
result = services.resource_access_tokens.create(
RequestContext(
request_id="resource-token-regression",
trace_id=None,
account_id=str(uuid4()),
active_workspace_id=harness.tenant.id,
),
name="Suggested questions integration",
resources=tuple(ResourceAccessTokenResource(ResourceAccessTokenResourceType.APP, app_id) for app_id in app_ids),
)
assert result.token.startswith("sk-")
assert all(row.last_used_at is None for row in result.rows)
harness.assert_closed()
return result
@pytest.mark.parametrize("multiple_apps", [False, True], ids=["implicit-single-app", "explicit-multiple-apps"])
def test_resource_access_token_admits_bound_app_and_records_usage(harness: _Harness, multiple_apps: bool) -> None:
app_ids = (harness.target.id,)
if multiple_apps:
other = make_app(app_id=str(uuid4()), tenant_id=harness.tenant.id)
with harness.factory.begin() as session:
session.add(other)
app_ids = (other.id, harness.target.id)
token = _create_resource_token(harness, app_ids=app_ids)
response = harness.get(
authorization=f"Bearer {token.token}",
requested_app_id=harness.target.id if multiple_apps else None,
)
assert response.status_code == 200
assert response.json == {"result": "success", "data": []}
assert response.headers["Content-Type"] == "application/json"
assert len(harness.provisions) == len(harness.queries) == 1
harness.assert_closed()
with harness.factory() as session:
stored_token = session.get(ResourceAccessToken, token.token_id)
assert stored_token is not None
assert stored_token.last_used_at is not None
assert session.scalars(select(EndUser.id)).all() == [harness.end_user.id]
def test_multiple_app_resource_token_requires_app_header_before_provisioning(harness: _Harness) -> None:
other = make_app(app_id=str(uuid4()), tenant_id=harness.tenant.id)
with harness.factory.begin() as session:
session.add(other)
token = _create_resource_token(harness, app_ids=(harness.target.id, other.id))
response = harness.get(authorization=f"Bearer {token.token}", user="new-user")
assert response.status_code == 400
assert response.json == {
"code": "bad_request",
"message": "App ID is required when a resource access token is bound to multiple apps.",
"status": 400,
}
assert harness.provisions == harness.queries == []
harness.assert_closed()
with harness.factory() as session:
stored_token = session.get(ResourceAccessToken, token.token_id)
assert stored_token is not None
assert stored_token.last_used_at is None
assert session.scalars(select(EndUser.id)).all() == [harness.end_user.id]
@pytest.mark.parametrize(
("failure", "status", "code", "message"),
[
("unbound_app", 403, "forbidden", "Resource access token is not allowed to access this app."),
("foreign_app", 403, "forbidden", "Resource access token is not allowed to access this app."),
("no_bindings", 403, "forbidden", "Resource access token is not allowed to access this app."),
("foreign_binding", 403, "forbidden", "The app no longer exists."),
("deleted_app", 403, "forbidden", "The app no longer exists."),
("invalid_token", 401, "unauthorized", "Resource access token is invalid."),
],
)
def test_resource_token_denials_stop_before_end_user_provisioning(
harness: _Harness, failure: str, status: int, code: str, message: str
) -> None:
token = _create_resource_token(harness, app_ids=(harness.target.id,))
requested_app_id = None
authorization = f"Bearer {token.token}"
with harness.factory.begin() as session:
if failure in {"unbound_app", "foreign_app", "foreign_binding"}:
tenant_id = harness.tenant.id
if failure != "unbound_app":
foreign_tenant = Tenant(name="Other resource owner")
session.add(foreign_tenant)
tenant_id = foreign_tenant.id
other = make_app(app_id=str(uuid4()), tenant_id=tenant_id)
session.add(other)
if failure == "foreign_binding":
relation = session.scalar(
select(ResourceAccessTokenRelation).where(ResourceAccessTokenRelation.token_id == token.token_id)
)
assert relation is not None
relation.app_id = other.id
else:
requested_app_id = other.id
elif failure == "no_bindings":
session.execute(
delete(ResourceAccessTokenRelation).where(ResourceAccessTokenRelation.token_id == token.token_id)
)
elif failure == "deleted_app":
session.execute(delete(App).where(App.id == harness.target.id))
else:
authorization = f"Bearer sk-{uuid4()}"
response = harness.get(authorization=authorization, requested_app_id=requested_app_id, user="new-user")
assert response.status_code == status
assert response.json == {"code": code, "message": message, "status": status}
assert harness.provisions == harness.queries == []
harness.assert_closed()
with harness.factory() as session:
stored_token = session.get(ResourceAccessToken, token.token_id)
assert stored_token is not None
assert stored_token.last_used_at is None
assert session.scalars(select(EndUser.id)).all() == [harness.end_user.id]
@pytest.mark.parametrize(
("failure", "code", "message"),
[
("disabled_api", "app_api_disabled", "The app's API service has been disabled."),
("archived_tenant", "workspace_archived", "The workspace's status is archived."),
],
)
def test_resource_token_keeps_app_and_workspace_policy_errors(
harness: _Harness, failure: str, code: str, message: str
) -> None:
token = _create_resource_token(harness, app_ids=(harness.target.id,))
with harness.factory.begin() as session:
if failure == "disabled_api":
target = session.get(App, harness.target.id)
assert target is not None
target.enable_api = False
else:
tenant = session.get(Tenant, harness.tenant.id)
assert tenant is not None
tenant.status = TenantStatus.ARCHIVE
response = harness.get(authorization=f"Bearer {token.token}", user="new-user")
assert response.status_code == 403
assert response.json == {"code": code, "message": message, "status": 403}
assert harness.provisions == harness.queries == []
harness.assert_closed()
with harness.factory() as session:
assert session.scalars(select(EndUser.id)).all() == [harness.end_user.id]
def test_explicit_service_api_app_tenant_scope_excludes_foreign_apps(harness: _Harness) -> None:
services = harness.app.extensions["application_services"]
assert isinstance(services, _Services)
record = services.app_definitions.get_service_api_app(harness.target.id, tenant_id=harness.tenant.id)
assert (record.app_id, record.tenant_id) == (harness.target.id, harness.tenant.id)
with pytest.raises(AppDefinitionUnavailableError):
services.app_definitions.get_service_api_app(harness.target.id, tenant_id=str(uuid4()))
assert services.app_definitions.get_service_api_app(harness.target.id) == record
assert harness.provisions == harness.queries == []
harness.assert_closed()
@@ -0,0 +1,430 @@
"""Web suggested questions through HTTP, real admission, and SQLite ownership queries."""
import json
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from decimal import Decimal
from http import HTTPStatus
from typing import Literal
from uuid import uuid4
import pytest
from flask import Flask
from sqlalchemy import Connection, Engine, delete, event, select, update
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
from werkzeug.test import TestResponse
import controllers.web.message as message_controller
from constants import HEADER_NAME_APP_CODE, HEADER_NAME_PASSPORT
from enums import DeploymentEdition
from extensions.ext_database import db
from graphon.model_runtime.entities.model_entities import ModelType
from libs.external_api import ExternalApi
from libs.passport import PassportService
from models.agent import (
Agent,
AgentConfigRevision,
AgentConfigRevisionOperation,
AgentConfigSnapshot,
AgentScope,
AgentSource,
)
from models.agent_config_entities import AgentSoulConfig
from models.enums import ConversationFromSource, CustomizeTokenStrategy
from models.model import App, AppMode, AppModelConfig, Conversation, EndUser, Message, Site
from models.provider import Provider, TenantDefaultModel
from models.workflow import Workflow
from repositories.message_repository import MessageRepository
from services.message_suggested_questions_generator import SuggestedQuestionsGenerator
from services.message_suggested_questions_queries import SuggestedQuestionsQuery
from services.message_suggested_questions_service import (
MessageSuggestedQuestions,
MessageSuggestedQuestionsService,
)
from tests.helpers.unit.model_factories import make_app, make_conversation, make_end_user, make_message
@dataclass
class _Services:
message_suggested_questions: MessageSuggestedQuestions
@dataclass(frozen=True)
class _Harness:
app: Flask
target: App
end_user: EndUser
message: Message
factory: sessionmaker[Session]
admission_factory: sessionmaker[Session]
passport: str
sessions: list[Session]
def get(self) -> TestResponse:
return self.app.test_client().get(
f"/messages/{self.message.id}/suggested-questions",
headers={HEADER_NAME_APP_CODE: "public-app", HEADER_NAME_PASSPORT: self.passport},
)
def assert_closed(self, *, caller: Session | None = None) -> None:
assert self.sessions
assert all(
not session.in_transaction() and not session.identity_map
for session in self.sessions
if session is not caller
)
@pytest.fixture
def harness(
_empty_model_provider_daemon: None,
config_overrides: Callable[..., None],
sqlite_engine: Engine,
sqlite_session_factory: sessionmaker[Session],
) -> Iterator[_Harness]:
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY, SECRET_KEY="suggested-questions-test-secret-key")
target = make_app(app_id=str(uuid4()), tenant_id=str(uuid4()))
end_user = make_end_user(end_user_id=str(uuid4()), tenant_id=target.tenant_id, app_id=target.id)
config = AppModelConfig(
app_id=target.id,
suggested_questions_after_answer=json.dumps({"enabled": True, "prompt": "Ask concise questions"}),
)
site = Site(
app_id=target.id,
title="Public app",
default_language="en-US",
customize_token_strategy=CustomizeTokenStrategy.UUID,
code="public-app",
)
conversation = make_conversation(
conversation_id=str(uuid4()),
app_id=target.id,
from_source=ConversationFromSource.API,
from_end_user_id=end_user.id,
inputs={},
)
message = make_message(
message_id=str(uuid4()),
app_id=target.id,
conversation_id=conversation.id,
inputs={},
message={},
query="How does it work?",
answer="Like this.",
message_unit_price=Decimal(0),
answer_unit_price=Decimal(0),
currency="USD",
from_source=ConversationFromSource.API,
from_end_user_id=end_user.id,
)
with sqlite_session_factory.begin() as session:
# The real provider lookup rejects this stored ID before plugin discovery.
# Generation remains best-effort and must return an empty list.
provider = Provider(tenant_id=target.tenant_id, provider_name="invalid/provider", is_valid=True)
session.add(
TenantDefaultModel(
tenant_id=target.tenant_id,
provider_name="invalid/provider",
model_name="invalid-model",
model_type=ModelType.LLM,
)
)
session.add_all([target, end_user, config, site, conversation, message, provider])
session.flush()
target.app_model_config_id = config.id
conversation.app_model_config_id = config.id
read_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
admission_factory = sqlite_session_factory
sessions: list[Session] = []
def track_session(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
sessions.append(session)
admission_sessions: list[Session] = []
def track_admission(session: Session, transaction: SessionTransaction, connection: Connection) -> None:
admission_sessions.append(session)
track_session(session, transaction, connection)
event.listen(admission_factory, "after_begin", track_admission)
event.listen(read_factory, "after_begin", track_session)
passport = PassportService().issue({"app_code": site.code, "app_id": target.id, "end_user_id": end_user.id})
app = Flask(__name__)
app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False, SQLALCHEMY_DATABASE_URI=str(sqlite_engine.url))
db.init_app(app)
event.listen(db.session.session_factory, "after_begin", track_session)
queries = SuggestedQuestionsQuery(session_factory=read_factory, repository_factory=MessageRepository)
services = _Services(MessageSuggestedQuestionsService(queries=queries, generator=SuggestedQuestionsGenerator()))
app.extensions["application_services"] = services
api = ExternalApi(app)
api.add_resource(message_controller.MessageSuggestedQuestionApi, "/messages/<uuid:message_id>/suggested-questions")
yield _Harness(
app,
target,
end_user,
message,
sqlite_session_factory,
admission_factory,
passport,
sessions,
)
event.remove(admission_factory, "after_begin", track_admission)
event.remove(read_factory, "after_begin", track_session)
event.remove(db.session.session_factory, "after_begin", track_session)
with app.app_context():
db.session.remove()
db.engine.dispose()
@pytest.mark.parametrize("mode", [AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
def test_chat_modes_return_empty_questions_when_stored_provider_config_is_invalid(
harness: _Harness,
mode: AppMode,
caplog: pytest.LogCaptureFixture,
) -> None:
with harness.factory.begin() as session:
session.execute(update(App).where(App.id == harness.target.id).values(mode=mode))
if mode == AppMode.ADVANCED_CHAT:
workflow = Workflow.new(
tenant_id=harness.target.tenant_id,
app_id=harness.target.id,
type="chat",
version="published",
graph="{}",
features='{"suggested_questions_after_answer":{"enabled":true}}',
created_by=str(uuid4()),
environment_variables=[],
conversation_variables=[],
rag_pipeline_variables=[],
)
session.add(workflow)
session.flush()
app_model = session.get(App, harness.target.id)
assert app_model is not None
app_model.workflow_id = workflow.id
elif mode == AppMode.AGENT:
agent = Agent(
tenant_id=harness.target.tenant_id,
app_id=harness.target.id,
name="Agent",
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
)
session.add(agent)
session.flush()
snapshot = AgentConfigSnapshot(
tenant_id=agent.tenant_id,
agent_id=agent.id,
version=1,
config_snapshot=AgentSoulConfig.model_validate(
{"app_features": {"suggested_questions_after_answer": {"enabled": True}}}
),
)
session.add(snapshot)
session.flush()
agent.active_config_snapshot_id = snapshot.id
session.add(
AgentConfigRevision(
tenant_id=agent.tenant_id,
agent_id=agent.id,
current_snapshot_id=snapshot.id,
revision=1,
operation=AgentConfigRevisionOperation.PUBLISH_DRAFT,
)
)
response = harness.get()
assert response.status_code == HTTPStatus.OK
assert response.headers["Content-Type"] == "application/json"
assert response.get_json() == {"data": []}
failures = [
record.exc_info[1]
for record in caplog.records
if record.name == "services.message_suggested_questions_generator" and record.exc_info is not None
]
assert len(failures) == 1
assert isinstance(failures[0], ValueError)
assert str(failures[0]) == "Invalid provider: invalid/provider"
harness.assert_closed()
@pytest.mark.parametrize("mode", [AppMode.COMPLETION, AppMode.WORKFLOW])
def test_non_chat_app_is_rejected_before_shared_capability(harness: _Harness, mode: AppMode) -> None:
with harness.factory.begin() as session:
session.execute(update(App).where(App.id == harness.target.id).values(mode=mode))
if mode == AppMode.WORKFLOW:
workflow = Workflow.new(
tenant_id=harness.target.tenant_id,
app_id=harness.target.id,
type="workflow",
version="published",
graph="{}",
features="{}",
created_by=str(uuid4()),
environment_variables=[],
conversation_variables=[],
rag_pipeline_variables=[],
)
session.add(workflow)
session.flush()
session.execute(update(App).where(App.id == harness.target.id).values(workflow_id=workflow.id))
response = harness.get()
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.get_json()["code"] == "not_chat_app"
harness.assert_closed()
@pytest.mark.parametrize(
("resource", "status", "code", "message"),
[
("agent_version", 404, "app_not_found", "App not found"),
("message", 404, "not_found", "Message not found"),
("conversation", 404, "not_found", "Conversation not found"),
("disabled", 403, "app_suggested_questions_after_answer_disabled", "disabled"),
],
)
def test_resource_errors_keep_specific_http_codes(
harness: _Harness,
resource: Literal["agent_version", "message", "conversation", "disabled"],
status: int,
code: str,
message: str,
) -> None:
with harness.factory.begin() as session:
if resource == "message":
session.execute(delete(Message).where(Message.id == harness.message.id))
elif resource == "conversation":
session.execute(delete(Conversation).where(Conversation.id == harness.message.conversation_id))
elif resource == "disabled":
session.execute(
update(AppModelConfig)
.where(AppModelConfig.app_id == harness.target.id)
.values(suggested_questions_after_answer='{"enabled":false}')
)
else:
session.execute(update(App).where(App.id == harness.target.id).values(mode=AppMode.AGENT))
session.add(
Agent(
tenant_id=harness.target.tenant_id,
app_id=harness.target.id,
name="Broken agent",
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
active_config_snapshot_id=str(uuid4()),
)
)
response = harness.get()
assert response.status_code == status
assert response.headers["Content-Type"] == "application/json"
body = response.get_json()
assert body["code"] == code
assert body["status"] == status
assert message in body["message"]
harness.assert_closed()
@pytest.mark.parametrize(
("resource", "status", "code", "message"),
[
("end_user", 404, "not_found", "End user not found"),
(
"app_mode",
400,
"app_unavailable",
"App unavailable, please check your app configurations.",
),
],
)
def test_resource_changes_after_admission_keep_specific_errors(
harness: _Harness,
resource: Literal["end_user", "app_mode"],
status: int,
code: str,
message: str,
) -> None:
# Change persisted state after the real admission transaction ends; the
# application service and repository must reject the now-stale reference.
def change_resource(_session: Session, _transaction: SessionTransaction) -> None:
with harness.factory.begin() as session:
if resource == "end_user":
session.execute(delete(EndUser).where(EndUser.id == harness.end_user.id))
else:
session.execute(update(App).where(App.id == harness.target.id).values(mode=AppMode.WORKFLOW))
event.listen(harness.admission_factory, "after_transaction_end", change_resource, once=True)
try:
response = harness.get()
finally:
event.remove(harness.admission_factory, "after_transaction_end", change_resource)
assert response.status_code == status
assert response.get_json()["code"] == code
assert response.get_json()["message"] == message
harness.assert_closed()
def test_model_resolution_failure_preserves_caller_session_and_pending_edits(harness: _Harness) -> None:
with harness.app.app_context():
caller = db.session()
app_model = caller.get(App, harness.target.id)
assert app_model is not None
app_model.name = "Uncommitted caller change"
response = harness.get()
assert response.status_code == HTTPStatus.OK
assert response.get_json() == {"data": []}
assert db.session() is caller
assert caller.in_transaction()
assert app_model in caller.dirty
harness.assert_closed(caller=caller)
with harness.factory() as session:
persisted = session.get(App, harness.target.id)
assert persisted is not None
assert persisted.name == harness.target.name
@pytest.mark.parametrize("field_name", ["tenant_id", "app_id"])
def test_admission_rejects_end_user_from_another_owner(
harness: _Harness,
field_name: Literal["tenant_id", "app_id"],
) -> None:
with harness.factory.begin() as session:
session.execute(update(EndUser).where(EndUser.id == harness.end_user.id).values({field_name: str(uuid4())}))
response = harness.get()
assert response.status_code == HTTPStatus.NOT_FOUND
# Passport admission rejects the owner mismatch before the message service.
assert response.get_json()["code"] == "not_found"
harness.assert_closed()
@pytest.mark.parametrize("model", [Message, Conversation])
@pytest.mark.parametrize("field_name", ["app_id", "from_end_user_id", "from_source"])
def test_real_runtime_rejects_message_or_conversation_outside_actor_scope(
harness: _Harness,
model: type[Message] | type[Conversation],
field_name: Literal["app_id", "from_end_user_id", "from_source"],
) -> None:
record_id = harness.message.id if model is Message else harness.message.conversation_id
value = ConversationFromSource.CONSOLE if field_name == "from_source" else str(uuid4())
with harness.factory.begin() as session:
session.execute(update(model).where(model.id == record_id).values({field_name: value}))
response = harness.get()
assert response.status_code == HTTPStatus.NOT_FOUND
assert response.get_json()["message"] == ("Message not found" if model is Message else "Conversation not found")
harness.assert_closed()
def test_missing_saved_default_returns_empty_questions_without_creating_a_default(harness: _Harness) -> None:
with harness.factory.begin() as session:
session.execute(delete(TenantDefaultModel).where(TenantDefaultModel.tenant_id == harness.target.tenant_id))
response = harness.get()
assert response.status_code == HTTPStatus.OK
assert response.get_json() == {"data": []}
with harness.factory() as session:
assert (
session.scalar(
select(TenantDefaultModel.id).where(TenantDefaultModel.tenant_id == harness.target.tenant_id)
)
is None
)
harness.assert_closed()
@@ -0,0 +1,45 @@
"""Opt-in HTTP fixtures for provider discovery without a deployed plugin daemon."""
from collections.abc import Callable, Iterator
from threading import Thread
import pytest
from flask import Flask
from werkzeug.serving import make_server
from yarl import URL
from core.plugin.impl import base as plugin_base
@pytest.fixture(scope="session")
def _empty_model_provider_daemon_url() -> Iterator[URL]:
app = Flask(__name__)
@app.get("/plugin/<tenant_id>/management/models")
def list_model_providers(tenant_id: str) -> dict[str, object]:
return {"code": 0, "message": "success", "data": []}
server = make_server("127.0.0.1", 0, app, threaded=True)
thread = Thread(target=server.serve_forever, kwargs={"poll_interval": 0.01}, daemon=True)
thread.start()
try:
yield URL(f"http://127.0.0.1:{server.server_port}")
finally:
server.shutdown()
thread.join()
server.server_close()
@pytest.fixture
def _empty_model_provider_daemon(
_empty_model_provider_daemon_url: URL,
config_overrides: Callable[..., None],
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Run real model discovery against an empty installation over loopback HTTP.
Skip the global unit-test Redis mock so a cache response cannot determine
whether malformed stored providers reach their intended resolution error.
"""
config_overrides(PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED=False)
monkeypatch.setattr(plugin_base, "plugin_daemon_inner_api_baseurl", _empty_model_provider_daemon_url)
+5 -1
View File
@@ -39,7 +39,11 @@ PROJECT_DIR = os.path.abspath(os.path.join(ABS_PATH, os.pardir, os.pardir))
CACHED_APP = Flask(__name__)
pytest_plugins = ("tests.helpers.unit.audio_runtime_fixtures", "tests.helpers.unit.agent_runtime_fixtures")
pytest_plugins = (
"tests.helpers.unit.audio_runtime_fixtures",
"tests.helpers.unit.agent_runtime_fixtures",
"tests.helpers.unit.model_provider_fixtures",
)
# set global mock for Redis client
redis_mock = MagicMock()
@@ -1,81 +1,28 @@
from __future__ import annotations
import json
from collections.abc import Iterator, Sequence
from dataclasses import dataclass, field
from datetime import UTC, datetime
from decimal import Decimal
from http import HTTPStatus
from inspect import unwrap
from typing import cast
from unittest.mock import MagicMock
from uuid import uuid4
import pytest
from flask import Flask
from sqlalchemy import Connection, Engine, event, func, select, text, update
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
from werkzeug.test import TestResponse
from sqlalchemy import event
from sqlalchemy.orm import Session
import controllers.console.wraps as console_wraps
from controllers.common.rbac import RBACPermission, RBACResourceScope
from controllers.common.rbac.checks import RBACService
from controllers.console.app import message as controller
from controllers.console.app import message as message_module
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from core.model_manager import ModelInstance
from core.ops.ops_trace_manager import TraceTask
from extensions.application_services.app import AppServices
from extensions.application_services.rbac import RBACServices
from extensions.ext_database import db
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage, PromptMessage
from graphon.model_runtime.entities.model_entities import ModelType
from graphon.model_runtime.errors.invoke import InvokeError
from libs import login
from libs.external_api import ExternalApi
from models.account import Account, Tenant
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigVersionKind,
AgentDebugConversation,
AgentScope,
AgentSource,
AgentStatus,
AgentWorkspaceBinding,
)
from models.agent_config_entities import AgentSoulConfig
from models.account import Account
from models.enums import ConversationFromSource, CreatorUserRole, FeedbackFromSource, FeedbackRating
from models.model import (
App,
AppAnnotationHitHistory,
AppMode,
AppModelConfig,
Conversation,
Message,
MessageAgentThought,
MessageAnnotation,
MessageFeedback,
)
from repositories.app.agent_app_repository import AgentAppRepository
from services import message_service
from services.app.agent_app_service import AgentAppAccessService
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.errors.conversation import ConversationNotExistsError
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
from services.message_suggested_questions_adapters import MessageSuggestedQuestionsRuntime
from services.message_suggested_questions_service import (
MessageSuggestedQuestions,
SuggestedQuestionsAccount,
SuggestedQuestionsActor,
SuggestedQuestionsActorNotFoundError,
)
from tests.helpers.unit.config_override import apply_config_overrides
from tests.helpers.unit.model_factories import make_account, make_app, make_conversation, make_message
from tests.helpers.unit.rbac_fakes import RBACDomain
def _account() -> Account:
@@ -358,13 +305,6 @@ class TestMessageApiCases:
response = message_module.AnnotationCountResponse(count=10)
assert response.count == 10
@staticmethod
def test_suggested_questions_response(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
"""Test SuggestedQuestionsResponse creation."""
response = message_module.SuggestedQuestionsResponse(data=["What is AI?", "How does ML work?"])
assert len(response.data) == 2
assert response.data[0] == "What is AI?"
@staticmethod
def test_message_detail_response_normalizes_aliases_and_timestamp(
app: Flask, monkeypatch: pytest.MonkeyPatch
@@ -432,514 +372,3 @@ class TestMessageApiCases:
"parent_message_id": None,
"extra_contents": [],
}
"""Console suggested-question admission and actor/configuration isolation over SQLite."""
@dataclass
class _Questions:
questions: list[str] = field(default_factory=lambda: ["Next?"])
failure: Exception | None = None
calls: list[tuple[str, str, str, SuggestedQuestionsActor, str]] = field(default_factory=list)
def get_suggested_questions(
self,
*,
app_id: str,
app_owner_tenant_id: str,
expected_app_mode: str,
actor: SuggestedQuestionsActor,
message_id: str,
) -> list[str]:
self.calls.append((app_id, app_owner_tenant_id, expected_app_mode, actor, message_id))
if self.failure is not None:
raise self.failure
return self.questions
@dataclass
class _Agents:
access: AgentAppAccessService
@dataclass
class _Services:
apps: AppServices
agent_apps: _Agents
message_suggested_questions: MessageSuggestedQuestions
rbac: RBACServices
@dataclass
class _Provider:
tenant_id: str
admission_sessions: list[Session]
prompts: list[str] = field(default_factory=list)
traces: list[TraceTask] = field(default_factory=list)
def get_default_model_instance(self, *, tenant_id: str, model_type: ModelType) -> ModelInstance:
assert tenant_id == self.tenant_id
assert model_type == ModelType.LLM
assert self.admission_sessions
assert all(not session.in_transaction() and not session.identity_map for session in self.admission_sessions)
return cast(ModelInstance, self)
def get_llm_num_tokens(self, prompt_messages: Sequence[PromptMessage]) -> int:
return len(prompt_messages)
def get_model_schema(self) -> None:
raise NotImplementedError
def invoke_llm(
self,
*,
prompt_messages: list[PromptMessage],
model_parameters: dict[str, object],
stop: list[str],
stream: bool,
) -> LLMResult:
assert model_parameters == {"max_tokens": 256, "temperature": 0.0}
assert stop == []
assert stream is False
self.prompts.append(prompt_messages[0].get_text_content())
return LLMResult(
model="test-model", message=AssistantPromptMessage(content='["Next?"]'), usage=LLMUsage.empty_usage()
)
def add_trace_task(self, task: TraceTask) -> None:
self.traces.append(task)
@dataclass
class _Harness:
flask_app: Flask
target: App
account: Account
conversation: Conversation
message: Message
agent_id: str
factory: sessionmaker[Session]
services: _Services
provider: _Provider
permission_calls: list[tuple[str, str, RBACPermission, RBACResourceScope | None, str | None]]
allowed: bool = True
def get(self, route: str = "app") -> TestResponse:
resource = f"apps/{self.target.id}" if route == "app" else f"agent/{self.agent_id}"
return self.flask_app.test_client().get(f"/{resource}/chat-messages/{self.message.id}/suggested-questions")
def add_agent(self, *, scope: AgentScope = AgentScope.ROSTER, backing: bool = True) -> None:
with self.factory.begin() as session:
session.execute(update(App).where(App.id == self.target.id).values(mode=AppMode.AGENT))
session.add(
Agent(
id=self.agent_id,
tenant_id=self.target.tenant_id,
name="Agent",
scope=scope,
source=AgentSource.AGENT_APP if scope == AgentScope.ROSTER else AgentSource.WORKFLOW,
status=AgentStatus.ACTIVE,
app_id=self.target.id if scope == AgentScope.ROSTER else str(uuid4()),
backing_app_id=self.target.id if scope == AgentScope.WORKFLOW_ONLY and backing else None,
workflow_id=str(uuid4()) if scope == AgentScope.WORKFLOW_ONLY else None,
workflow_node_id="node" if scope == AgentScope.WORKFLOW_ONLY else None,
)
)
class TestMessageSuggestedQuestionsCases:
@staticmethod
@pytest.fixture
def harness(
monkeypatch: pytest.MonkeyPatch,
sqlite_engine: Engine,
sqlite_session_factory: sessionmaker[Session],
app_services: AppServices,
rbac_domain: RBACDomain,
) -> Iterator[_Harness]:
target = make_app(app_id=str(uuid4()), tenant_id=str(uuid4()))
account = make_account(account_id=str(uuid4()))
tenant = Tenant(name="Workspace")
tenant.id = target.tenant_id
account._current_tenant = tenant
conversation = make_conversation(
conversation_id=str(uuid4()),
app_id=target.id,
from_source=ConversationFromSource.CONSOLE,
from_account_id=account.id,
inputs={},
)
config = AppModelConfig(
app_id=target.id,
suggested_questions_after_answer=json.dumps({"enabled": True, "prompt": "Console questions"}),
)
message = make_message(
message_id=str(uuid4()),
app_id=target.id,
conversation_id=conversation.id,
inputs={},
message={},
query="How?",
answer="Like this.",
message_unit_price=Decimal(0),
answer_unit_price=Decimal(0),
currency="USD",
from_source=ConversationFromSource.CONSOLE,
from_account_id=account.id,
)
with sqlite_session_factory.begin() as session:
session.add_all([target, account, conversation, config, message])
session.flush()
target.app_model_config_id = config.id
conversation.app_model_config_id = config.id
sessions: list[Session] = []
def track_session(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
sessions.append(session)
event.listen(sqlite_session_factory, "after_begin", track_session)
provider = _Provider(target.tenant_id, sessions)
def manager(*, tenant_id: str) -> _Provider:
assert tenant_id == target.tenant_id
return provider
def trace_manager(*, app_id: str) -> _Provider:
assert app_id == target.id
return provider
monkeypatch.setattr(message_service.ModelManager, "for_tenant", manager)
monkeypatch.setattr(message_service, "TraceQueueManager", trace_manager)
monkeypatch.setattr(login, "current_user", account)
monkeypatch.setattr(console_wraps, "_is_setup_completed", lambda: True)
apply_config_overrides(monkeypatch, LOGIN_DISABLED=True, RBAC_ENABLED=False)
flask_app = Flask(__name__)
flask_app.config.update(
TESTING=True, RESTX_ERROR_404_HELP=False, SQLALCHEMY_DATABASE_URI=str(sqlite_engine.url)
)
db.init_app(flask_app)
services = _Services(
app_services,
_Agents(AgentAppAccessService(references=AgentAppRepository(session_factory=sqlite_session_factory))),
MessageSuggestedQuestionsRuntime(session_factory=sqlite_session_factory),
rbac_domain.rbac,
)
flask_app.extensions["application_services"] = services
api = ExternalApi(flask_app)
api.add_resource(
controller.MessageSuggestedQuestionApi,
"/apps/<uuid:app_id>/chat-messages/<uuid:message_id>/suggested-questions",
)
api.add_resource(
controller.AgentMessageSuggestedQuestionApi,
"/agent/<uuid:agent_id>/chat-messages/<uuid:message_id>/suggested-questions",
)
result = _Harness(
flask_app,
target,
account,
conversation,
message,
str(uuid4()),
sqlite_session_factory,
services,
provider,
[],
)
def permission(
tenant_id: str,
account_id: str,
*,
scene: RBACPermission,
resource_type: RBACResourceScope | None,
resource_id: str | None,
) -> bool:
result.permission_calls.append((tenant_id, account_id, scene, resource_type, resource_id))
return result.allowed
monkeypatch.setattr(RBACService.CheckAccess, "check", permission)
yield result
event.remove(sqlite_session_factory, "after_begin", track_session)
with flask_app.app_context():
db.session.remove()
db.engine.dispose()
@staticmethod
@pytest.mark.parametrize("mode", [AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT])
@pytest.mark.parametrize("questions", [[], ["Next?", "More?"]])
def test_app_reference_and_account_are_passed_to_shared_runtime(
harness: _Harness, mode: AppMode, questions: list[str]
) -> None:
with harness.factory.begin() as session:
session.execute(update(App).where(App.id == harness.target.id).values(mode=mode))
port = _Questions(questions=questions)
harness.services.message_suggested_questions = port
response = harness.get()
assert response.status_code == HTTPStatus.OK
assert response.headers["Content-Type"] == "application/json"
assert response.get_json() == {"data": questions}
assert port.calls == [
(
harness.target.id,
harness.target.tenant_id,
mode,
SuggestedQuestionsAccount(harness.account.id, "debugger"),
harness.message.id,
)
]
assert all(
not session.in_transaction() and not session.identity_map for session in harness.provider.admission_sessions
)
@staticmethod
@pytest.mark.parametrize("change", ["tenant", "status", "missing", "workflow", "completion", "hidden"])
def test_app_admission_rejects_unavailable_or_unsupported_apps(harness: _Harness, change: str) -> None:
if change == "hidden":
harness.add_agent(scope=AgentScope.WORKFLOW_ONLY)
else:
field_name, value = {
"tenant": ("tenant_id", str(uuid4())),
"status": ("status", "archived"),
"missing": ("id", str(uuid4())),
"workflow": ("mode", "workflow"),
"completion": ("mode", "completion"),
}[change]
with harness.factory.begin() as session:
if change == "status":
session.execute(
text("UPDATE apps SET status = :status WHERE id = :id"),
{"status": value, "id": harness.target.id},
)
else:
session.execute(update(App).where(App.id == harness.target.id).values({field_name: value}))
port = _Questions()
harness.services.message_suggested_questions = port
response = harness.get()
assert response.status_code == HTTPStatus.NOT_FOUND
assert response.get_json()["code"] == "app_not_found"
assert not port.calls
@staticmethod
@pytest.mark.parametrize("scope", [AgentScope.ROSTER, AgentScope.WORKFLOW_ONLY])
def test_agent_resolves_existing_backing_app(harness: _Harness, scope: AgentScope) -> None:
harness.add_agent(scope=scope)
port = _Questions()
harness.services.message_suggested_questions = port
response = harness.get("agent")
assert response.status_code == HTTPStatus.OK
assert port.calls == [
(
harness.target.id,
harness.target.tenant_id,
AppMode.AGENT,
SuggestedQuestionsAccount(harness.account.id, "debugger"),
harness.message.id,
)
]
assert all(
not session.in_transaction() and not session.identity_map for session in harness.provider.admission_sessions
)
@staticmethod
@pytest.mark.parametrize("change", ["missing_backing", "tenant", "agent_status", "app_status", "app_mode"])
def test_agent_rejects_unavailable_runtime_without_creating_app(harness: _Harness, change: str) -> None:
harness.add_agent(scope=AgentScope.WORKFLOW_ONLY, backing=change != "missing_backing")
if change != "missing_backing":
model, key, value = {
"tenant": (Agent, "tenant_id", str(uuid4())),
"agent_status": (Agent, "status", AgentStatus.ARCHIVED),
"app_status": (App, "status", "archived"),
"app_mode": (App, "mode", AppMode.CHAT),
}[change]
with harness.factory.begin() as session:
if change == "app_status":
session.execute(
text("UPDATE apps SET status = :status WHERE id = :id"),
{"status": value, "id": harness.target.id},
)
else:
session.execute(update(model).values({key: value}))
response = harness.get("agent")
assert response.status_code == HTTPStatus.NOT_FOUND
assert response.get_json()["code"] == "agent_not_found_error"
with harness.factory() as session:
assert session.scalar(select(func.count()).select_from(App)) == 1
assert session.scalar(select(func.count()).select_from(AppModelConfig)) == 1
if change == "missing_backing":
assert session.scalar(select(Agent.backing_app_id)) is None
@staticmethod
@pytest.mark.parametrize("route", ["app", "agent"])
@pytest.mark.parametrize("allowed", [True, False])
def test_real_rbac_admission_keeps_resource_scene(
harness: _Harness, monkeypatch: pytest.MonkeyPatch, route: str, allowed: bool
) -> None:
apply_config_overrides(monkeypatch, RBAC_ENABLED=True)
if route == "agent":
harness.add_agent()
harness.allowed = allowed
port = _Questions()
harness.services.message_suggested_questions = port
response = harness.get(route)
assert response.status_code == (HTTPStatus.OK if allowed else HTTPStatus.FORBIDDEN)
assert harness.permission_calls == [
(
harness.target.tenant_id,
harness.account.id,
RBACPermission.APP_VIEW_LAYOUT if route == "app" else RBACPermission.AGENT_TEST_AND_RUN,
RBACResourceScope.APP if route == "app" else RBACResourceScope.AGENT,
harness.target.id if route == "app" else harness.agent_id,
)
]
assert len(port.calls) == int(allowed)
@staticmethod
@pytest.mark.parametrize(
("error", "status", "code"),
[
(AppDefinitionUnavailableError("stale app"), 400, "app_unavailable"),
(SuggestedQuestionsActorNotFoundError("deleted account"), 401, "unauthorized"),
(MessageNotExistsError(), 404, "not_found"),
(ConversationNotExistsError(), 404, "not_found"),
(ProviderTokenNotInitError(), 400, "provider_not_initialize"),
(QuotaExceededError(), 400, "provider_quota_exceeded"),
(ModelCurrentlyNotSupportError(), 400, "model_currently_not_support"),
(InvokeError("provider failed"), 400, "completion_request_error"),
(SuggestedQuestionsAfterAnswerDisabledError(), 403, "app_suggested_questions_after_answer_disabled"),
(RuntimeError("private credentials"), 500, "internal_server_error"),
],
)
@pytest.mark.parametrize("route", ["app", "agent"])
def test_precise_errors_are_translated_at_http_boundary(
harness: _Harness, error: Exception, status: int, code: str, route: str
) -> None:
if route == "agent":
harness.add_agent()
harness.services.message_suggested_questions = _Questions(failure=error)
response = harness.get(route)
assert response.status_code == status
assert response.get_json()["code"] == code
assert "private credentials" not in response.get_data(as_text=True)
@staticmethod
def test_chat_runtime_uses_console_history_after_reference_session_closed(harness: _Harness) -> None:
with harness.flask_app.app_context():
caller_session = db.session()
caller_app = caller_session.get(App, harness.target.id)
assert caller_app is not None
caller_app.name = "Pending caller edit"
response = harness.get()
assert db.session() is caller_session
assert caller_session.in_transaction()
assert caller_app in caller_session.dirty
assert response.status_code == HTTPStatus.OK
assert response.get_json() == {"data": ["Next?"]}
assert len(harness.provider.prompts) == 1
assert "Console questions" in harness.provider.prompts[0]
assert "How?" in harness.provider.prompts[0]
assert len(harness.provider.traces) == 1
@staticmethod
@pytest.mark.parametrize("entity", [Message, Conversation])
@pytest.mark.parametrize("change", ["app_id", "from_account_id", "from_source"])
def test_runtime_cannot_read_another_accounts_message_or_conversation(
harness: _Harness, entity: type[Message] | type[Conversation], change: str
) -> None:
with harness.factory.begin() as session:
session.execute(update(entity).values({change: "api" if change == "from_source" else str(uuid4())}))
response = harness.get()
assert response.status_code == HTTPStatus.NOT_FOUND
assert response.get_json()["message"] == (
"Message not found" if entity is Message else "Conversation not found"
)
assert not harness.provider.prompts
@staticmethod
def test_agent_runtime_uses_current_accounts_personal_debug_draft(harness: _Harness) -> None:
harness.add_agent()
with harness.factory.begin() as session:
session.execute(update(App).where(App.id == harness.target.id).values({App.app_model_config_id: None}))
session.add(
AgentDebugConversation(
tenant_id=harness.target.tenant_id,
agent_id=harness.agent_id,
app_id=harness.target.id,
account_id=harness.account.id,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
conversation_id=harness.conversation.id,
)
)
for owner, prompt in [
(harness.account.id, "My personal draft"),
(str(uuid4()), "Other accounts secret draft"),
]:
session.add(
AgentConfigDraft(
tenant_id=harness.target.tenant_id,
agent_id=harness.agent_id,
account_id=owner,
draft_owner_key=owner,
draft_type=AgentConfigDraftType.DEBUG_BUILD,
config_snapshot=AgentSoulConfig.model_validate(
{"app_features": {"suggested_questions_after_answer": {"enabled": True, "prompt": prompt}}}
),
)
)
response = harness.get("agent")
assert response.status_code == HTTPStatus.OK
assert response.get_json() == {"data": ["Next?"]}
assert "My personal draft" in harness.provider.prompts[0]
assert "Other accounts secret draft" not in harness.provider.prompts[0]
@staticmethod
@pytest.mark.parametrize("same_tenant", [True, False])
def test_agent_runtime_preserves_conversation_binding_scope(harness: _Harness, same_tenant: bool) -> None:
harness.add_agent()
draft_id, binding_id = str(uuid4()), str(uuid4())
with harness.factory.begin() as session:
session.execute(update(App).where(App.id == harness.target.id).values({App.app_model_config_id: None}))
session.add(
AgentConfigDraft(
id=draft_id,
tenant_id=harness.target.tenant_id,
agent_id=harness.agent_id,
draft_type=AgentConfigDraftType.DRAFT,
config_snapshot=AgentSoulConfig.model_validate(
{
"app_features": {
"suggested_questions_after_answer": {
"enabled": True,
"prompt": "Conversation bound questions",
}
}
}
),
)
)
session.add(
AgentWorkspaceBinding(
id=binding_id,
tenant_id=harness.target.tenant_id if same_tenant else str(uuid4()),
app_id=harness.target.id,
workspace_id=str(uuid4()),
agent_id=harness.agent_id,
agent_config_version_id=draft_id,
agent_config_version_kind=AgentConfigVersionKind.DRAFT,
backend_binding_ref="existing-binding",
)
)
session.execute(
update(Conversation)
.where(Conversation.id == harness.conversation.id)
.values({Conversation.agent_workspace_binding_id: binding_id})
)
response = harness.get("agent")
if same_tenant:
assert response.status_code == HTTPStatus.OK
assert "Conversation bound questions" in harness.provider.prompts[0]
else:
assert response.status_code == HTTPStatus.NOT_FOUND
assert response.get_json()["code"] == "agent_version_not_found_error"
assert not harness.provider.prompts
@@ -1,47 +1,33 @@
"""Explore message HTTP contracts through real admission, services and SQLite."""
import json
from collections.abc import Generator, Iterator, Mapping, Sequence
from dataclasses import dataclass, field, replace
from collections.abc import Generator, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Literal, cast
from typing import Literal
from uuid import uuid4
import pytest
from pydantic import JsonValue
from sqlalchemy import Connection, Engine, event, select, update
from sqlalchemy import Connection, event, select, update
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
from werkzeug.exceptions import Forbidden
from werkzeug.test import TestResponse
import controllers.console.explore.message as module
import services.message_service as message_module
from core.app.entities.app_invoke_entities import InvokeFrom
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
from core.model_manager import ModelInstance
from core.ops.ops_trace_manager import TraceTask
from extensions.ext_database import db
from graphon.model_runtime.entities import AssistantPromptMessage, PromptMessage
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage
from graphon.model_runtime.entities.model_entities import AIModelEntity, ModelType
from graphon.model_runtime.errors.invoke import InvokeError
from models import Account, App, AppMode, InstalledApp
from models import App, AppMode, InstalledApp
from models.enums import ConversationFromSource, ConversationStatus, FeedbackFromSource, FeedbackRating
from models.model import AppModelConfig, Conversation, Message, MessageFeedback
from models.model import Conversation, Message, MessageFeedback
from repositories.installed_app_message_repository import SQLAlchemyInstalledAppMessageRepository
from repositories.installed_app_repository import SQLAlchemyInstalledAppRepository
from services.errors.app import MoreLikeThisDisabledError
from services.errors.conversation import ConversationNotExistsError
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
from services.errors.message import MessageNotExistsError
from services.installed_app_generation_service import GenerationResponse, InstalledAppGenerationService
from services.installed_app_message_service import InstalledAppMessageService, MessageFeedbackEvent
from services.message_suggested_questions_adapters import MessageSuggestedQuestionsRuntime
from services.message_suggested_questions_service import (
MessageSuggestedQuestions,
SuggestedQuestionsAccount,
SuggestedQuestionsActor,
)
from tests.unit_tests.controllers.console.explore.test_installed_app_admission import (
_assert_json_response,
_Harness,
@@ -51,8 +37,8 @@ from tests.unit_tests.controllers.console.explore.test_installed_app_admission i
__all__ = ["harness"]
_CREATED_AT = datetime(2024, 1, 1)
type _Operation = Literal["list", "feedback", "more-like-this", "suggested-questions"]
_OPERATIONS: tuple[_Operation, ...] = ("list", "feedback", "more-like-this", "suggested-questions")
type _Operation = Literal["list", "feedback", "more-like-this"]
_OPERATIONS: tuple[_Operation, ...] = ("list", "feedback", "more-like-this")
_BLOCKING: dict[str, object] = {"answer": "你好", "metadata": {}, "usage": None}
@@ -65,7 +51,6 @@ class _InstalledAppServices:
@dataclass(frozen=True)
class _Services:
installed_apps: _InstalledAppServices
message_suggested_questions: MessageSuggestedQuestions
@dataclass
@@ -77,8 +62,6 @@ class _Messages:
extras: dict[str, list[dict[str, JsonValue]]] = field(default_factory=dict)
extra_calls: list[list[str]] = field(default_factory=list)
feedback_events: list[MessageFeedbackEvent] = field(default_factory=list)
questions: list[str] = field(default_factory=lambda: ["Next question?", "还有吗?"])
question_calls: list[tuple[str, str, str, SuggestedQuestionsActor, str]] = field(default_factory=list)
generation_calls: list[tuple[str, str, str, bool]] = field(default_factory=list)
generation_response: GenerationResponse = field(default_factory=lambda: dict(_BLOCKING))
external_error: Exception | None = None
@@ -100,21 +83,6 @@ class _Messages:
assert persisted.content == feedback.content
self.feedback_events.append(feedback)
def get_suggested_questions(
self,
*,
app_id: str,
app_owner_tenant_id: str,
expected_app_mode: str,
actor: SuggestedQuestionsActor,
message_id: str,
) -> list[str]:
self.assert_sessions_closed()
self.question_calls.append((app_id, app_owner_tenant_id, expected_app_mode, actor, message_id))
if self.external_error is not None:
raise self.external_error
return self.questions
def generate(
self, *, app_id: str, account_id: str, args: Mapping[str, object], streaming: bool
) -> GenerationResponse:
@@ -182,132 +150,17 @@ def messages(
usage=SQLAlchemyInstalledAppRepository(session_factory=factory), runtime=state
),
),
message_suggested_questions=state,
)
monkeypatch.setattr(module, "application_services", lambda: state.services)
for resource, suffix in (
(module.MessageListApi, ""),
(module.MessageFeedbackApi, "/<uuid:message_id>/feedbacks"),
(module.MessageMoreLikeThisApi, "/<uuid:message_id>/more-like-this"),
(module.MessageSuggestedQuestionApi, "/<uuid:message_id>/suggested-questions"),
):
harness.api.add_resource(resource, f"/installed-apps/<uuid:installed_app_id>/messages{suffix}")
return state
@dataclass
class _QuestionProvider:
messages: _Messages
message: Message
legacy_sessions: list[Session] = field(default_factory=list)
prompts: list[str] = field(default_factory=list)
io_sessions: list[tuple[str, bool]] = field(default_factory=list)
token_failure: bool = False
traces: list[TraceTask] = field(default_factory=list)
def get_default_model_instance(self, *, tenant_id: str, model_type: ModelType) -> ModelInstance:
assert tenant_id == self.messages.harness.target_app.tenant_id
assert model_type == ModelType.LLM
self.messages.assert_sessions_closed()
assert db.session.get(App, self.messages.harness.target_app.id) is not None
return cast(ModelInstance, self)
def record_io(self, stage: str) -> None:
self.io_sessions.append(
(
stage,
all(
not session.in_transaction() and not session.identity_map
for session in self.messages.sessions + self.legacy_sessions
),
)
)
def get_llm_num_tokens(self, prompt_messages: Sequence[PromptMessage]) -> int:
self.record_io("tokens")
if self.token_failure:
raise ValueError("Token counter failed")
return len(prompt_messages)
def get_model_schema(self) -> AIModelEntity:
self.record_io("schema")
return AIModelEntity.model_construct(parameter_rules=[])
def invoke_llm(
self,
*,
prompt_messages: list[PromptMessage],
model_parameters: Mapping[str, object],
stop: list[str],
stream: bool,
) -> LLMResult:
self.record_io("invoke")
assert stream is False
assert stop == []
assert model_parameters["temperature"] == 0.0
self.prompts.append(prompt_messages[0].get_text_content())
if self.messages.external_error is not None:
raise self.messages.external_error
return LLMResult(
model="questions-model",
message=AssistantPromptMessage(content=json.dumps(self.messages.questions)),
usage=LLMUsage.empty_usage(),
)
def add_trace_task(self, task: TraceTask) -> None:
self.traces.append(task)
def assert_closed(self) -> None:
self.messages.assert_sessions_closed()
assert all(not session.in_transaction() and not session.identity_map for session in self.legacy_sessions)
assert all(closed for _stage, closed in self.io_sessions), self.io_sessions
@pytest.fixture
def real_questions(
messages: _Messages, monkeypatch: pytest.MonkeyPatch, sqlite_engine: Engine
) -> Iterator[_QuestionProvider]:
config = AppModelConfig(
app_id=messages.harness.target_app.id,
suggested_questions_after_answer='{"enabled":true,"prompt":"Published questions"}',
)
with messages.factory.begin() as session:
session.add_all([messages.harness.account, config])
conversation = _conversation(messages)
with messages.factory.begin() as session:
session.execute(
update(Conversation).where(Conversation.id == conversation.id).values(app_model_config_id=config.id)
)
provider = _QuestionProvider(messages=messages, message=_message(messages, conversation))
def model_manager(*, tenant_id: str) -> _QuestionProvider:
assert tenant_id == messages.harness.target_app.tenant_id
return provider
def trace_manager(*, app_id: str) -> _QuestionProvider:
assert app_id == messages.harness.target_app.id
return provider
monkeypatch.setattr(message_module.ModelManager, "for_tenant", model_manager)
monkeypatch.setattr(message_module, "TraceQueueManager", trace_manager)
messages.services = replace(
messages.services,
message_suggested_questions=MessageSuggestedQuestionsRuntime(session_factory=messages.factory),
)
messages.harness.app.config["SQLALCHEMY_DATABASE_URI"] = str(sqlite_engine.url)
db.init_app(messages.harness.app)
def track_legacy(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
provider.legacy_sessions.append(session)
event.listen(db.session.session_factory, "after_begin", track_legacy)
yield provider
event.remove(db.session.session_factory, "after_begin", track_legacy)
with messages.harness.app.app_context():
db.session.remove()
db.engine.dispose()
def _set_mode(state: _Messages, mode: AppMode) -> None:
with state.factory.begin() as session:
app = session.get(App, state.harness.target_app.id)
@@ -608,12 +461,10 @@ def test_all_message_handlers_apply_installed_app_admission(
status=403 if admission == "denied" else 404,
code="access_denied" if admission == "denied" else "installed_app_not_found",
)
assert (
messages.extra_calls == messages.feedback_events == messages.question_calls == messages.generation_calls == []
)
assert messages.extra_calls == messages.feedback_events == messages.generation_calls == []
@pytest.mark.parametrize("operation", ["list", "more-like-this", "suggested-questions"])
@pytest.mark.parametrize("operation", ["list", "more-like-this"])
def test_mode_rejection_precedes_message_queries_and_generation(messages: _Messages, operation: _Operation) -> None:
_set_mode(messages, AppMode.CHAT if operation == "more-like-this" else AppMode.COMPLETION)
_error(
@@ -621,7 +472,7 @@ def test_mode_rejection_precedes_message_queries_and_generation(messages: _Messa
status=400,
code="not_completion_app" if operation == "more-like-this" else "not_chat_app",
)
assert messages.extra_calls == messages.question_calls == messages.generation_calls == []
assert messages.extra_calls == messages.generation_calls == []
def test_more_like_this_preserves_blocking_response_and_does_not_update_usage(messages: _Messages) -> None:
@@ -674,121 +525,7 @@ def test_more_like_this_invalid_query_uses_shared_422(messages: _Messages, query
assert messages.generation_calls == []
@pytest.mark.parametrize("questions", [["Next question?", "还有吗?"], []])
def test_suggested_questions_preserves_results_and_empty_fallback(messages: _Messages, questions: list[str]) -> None:
messages.questions = questions
message_id = str(uuid4())
_assert_json_response(
messages.request("suggested-questions", message_id=message_id), status=200, body={"data": questions}
)
assert messages.question_calls == [
(
messages.harness.target_app.id,
messages.harness.target_app.tenant_id,
"chat",
SuggestedQuestionsAccount(account_id=messages.harness.account.id, invoke_from="explore"),
message_id,
)
]
@pytest.mark.parametrize("failure", [None, InvokeError("Provider rejected input")])
def test_shared_suggested_questions_uses_owner_tenant_and_releases_sessions(
real_questions: _QuestionProvider, failure: Exception | None
) -> None:
messages = real_questions.messages
assert messages.harness.target_app.tenant_id != messages.harness.installed_app.tenant_id
messages.external_error = failure
response = messages.request("suggested-questions", message_id=real_questions.message.id)
_assert_json_response(response, status=200, body={"data": messages.questions if failure is None else []})
assert len(real_questions.traces) == 1
assert len(real_questions.prompts) == 1
assert real_questions.prompts[0].startswith("Human: Hello\nAssistant: 你好\nPublished questions\n")
assert len(real_questions.legacy_sessions) == 2
assert real_questions.legacy_sessions[0] is not real_questions.legacy_sessions[1]
assert [stage for stage, _closed in real_questions.io_sessions] == ["tokens", "schema", "invoke"]
real_questions.assert_closed()
messages.assert_unused()
@pytest.mark.parametrize("entity", ["message", "conversation"])
def test_shared_suggested_questions_rejects_other_account_history(
real_questions: _QuestionProvider, entity: str
) -> None:
messages = real_questions.messages
model = Message if entity == "message" else Conversation
record_id = real_questions.message.id if entity == "message" else real_questions.message.conversation_id
with messages.factory.begin() as session:
session.execute(update(model).where(model.id == record_id).values(from_account_id=str(uuid4())))
_error(
messages.request("suggested-questions", message_id=real_questions.message.id),
status=404,
code="message_not_found" if entity == "message" else "conversation_not_found",
)
assert not real_questions.prompts
real_questions.assert_closed()
@pytest.mark.parametrize("change", ["mode", "owner", "app", "account"])
def test_shared_suggested_questions_rejects_stale_admitted_app_and_actor(
real_questions: _QuestionProvider, change: str
) -> None:
messages = real_questions.messages
def change_admitted_resource() -> None:
with messages.factory.begin() as session:
if change == "account":
account = session.get(Account, messages.harness.account.id)
assert account is not None
session.delete(account)
return
app = session.get(App, messages.harness.target_app.id)
assert app is not None
if change == "mode":
app.mode = AppMode.COMPLETION
elif change == "owner":
app.tenant_id = str(uuid4())
else:
session.delete(app)
messages.harness.state.permission_action = change_admitted_resource
_error(
messages.request("suggested-questions", message_id=real_questions.message.id),
status=401 if change == "account" else 400,
code="unauthorized" if change == "account" else "app_unavailable",
message="Account no longer exists." if change == "account" else None,
)
assert not real_questions.prompts
assert not real_questions.legacy_sessions
real_questions.assert_closed()
def test_suggested_questions_uses_admitted_installation_until_next_request(real_questions: _QuestionProvider) -> None:
messages = real_questions.messages
def remove_installation() -> None:
with messages.factory.begin() as session:
installation = session.get(InstalledApp, messages.harness.installed_app.id)
assert installation is not None
session.delete(installation)
messages.harness.state.permission_action = remove_installation
_assert_json_response(
messages.request("suggested-questions", message_id=real_questions.message.id),
status=200,
body={"data": messages.questions},
)
messages.harness.state.permission_action = None
_error(
messages.request("suggested-questions", message_id=real_questions.message.id),
status=404,
code="installed_app_not_found",
)
assert len(real_questions.prompts) == 1
real_questions.assert_closed()
@pytest.mark.parametrize("operation", ["more-like-this", "suggested-questions"])
@pytest.mark.parametrize("operation", ["more-like-this"])
@pytest.mark.parametrize(
("failure", "status", "code", "description"),
[
@@ -813,7 +550,7 @@ def test_generation_failures_keep_provider_errors_and_context(
_set_mode(messages, AppMode.COMPLETION)
messages.external_error = failure
_error(messages.request(operation), status=status, code=code, message=description)
assert len(messages.generation_calls) + len(messages.question_calls) == 1
assert len(messages.generation_calls) == 1
messages.assert_unused()
@@ -823,15 +560,6 @@ def test_generation_failures_keep_provider_errors_and_context(
("more-like-this", MessageNotExistsError(), 404, "message_not_found"),
("more-like-this", MoreLikeThisDisabledError(), 403, "app_more_like_this_disabled"),
("more-like-this", ValueError("Bad runtime arguments"), 400, "invalid_param"),
("suggested-questions", MessageNotExistsError(), 404, "message_not_found"),
("suggested-questions", ConversationNotExistsError(), 404, "conversation_not_found"),
("suggested-questions", ValueError("Invalid legacy configuration"), 500, "internal_server_error"),
(
"suggested-questions",
SuggestedQuestionsAfterAnswerDisabledError(),
403,
"app_suggested_questions_after_answer_disabled",
),
],
)
def test_generation_resource_errors_remain_specific(
@@ -842,17 +570,3 @@ def test_generation_resource_errors_remain_specific(
messages.external_error = failure
_error(messages.request(operation), status=status, code=code)
messages.assert_unused()
def test_shared_suggested_questions_token_failure_releases_sessions(real_questions: _QuestionProvider) -> None:
real_questions.token_failure = True
_error(
real_questions.messages.request("suggested-questions", message_id=real_questions.message.id),
status=500,
code="internal_server_error",
)
assert [stage for stage, _closed in real_questions.io_sessions] == ["tokens"]
assert len(real_questions.legacy_sessions) == 1
assert not real_questions.prompts
assert not real_questions.traces
real_questions.assert_closed()
@@ -5,7 +5,6 @@ import zipfile
from collections.abc import Callable, Iterator, Mapping, Sequence
from dataclasses import dataclass, field
from datetime import datetime
from decimal import Decimal
from io import BytesIO
from types import SimpleNamespace
from typing import Literal, cast, override
@@ -29,14 +28,12 @@ import controllers.console.explore.trial as trial_module
import controllers.console.explore.trial_app_admission as _trial_audio_admission_module
import controllers.console.explore.trial_app_admission as _trial_file_upload_admission_module
import controllers.console.explore.trial_app_admission as _trial_generation_admission_module
import controllers.console.explore.trial_app_admission as _trial_suggested_questions_admission_module
import controllers.console.files as files_module
import controllers.console.remote_files as remote_module
import controllers.console.wraps as console_wraps
import libs.login as login_module
import services.app_service as app_module
import services.file_service as file_module
import services.message_service as message_module
from constants import COOKIE_NAME_CSRF_TOKEN, HEADER_NAME_CSRF_TOKEN
from controllers.common.errors import UnauthorizedError
from controllers.console import api as console_api
@@ -57,9 +54,6 @@ from core.errors.error import (
)
from core.file import remote_fetcher
from core.helper import encrypter
from core.model_context import get_credit_usage_metadata
from core.model_manager import ModelInstance
from core.ops.ops_trace_manager import TraceTask
from core.plugin.plugin_service import PluginService
from core.tools.__base.tool import Tool
from core.tools.entities.tool_entities import ToolParameter
@@ -67,30 +61,24 @@ from core.tools.errors import ToolSSRFError
from core.tools.tool_manager import ToolManager
from core.workflow.llm_environment_variable import LLMEnvironmentVariable
from enums import DeploymentEdition
from extensions.ext_database import db
from extensions.ext_login import DifyLoginManager, unauthorized_handler
from extensions.ext_storage import StorageType
from graphon.model_runtime.entities import AssistantPromptMessage, PromptMessage
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage
from graphon.model_runtime.entities.model_entities import AIModelEntity, ModelType
from graphon.model_runtime.errors.invoke import InvokeBadRequestError, InvokeError
from graphon.variables import SecretVariable, StringVariable
from libs.external_api import ExternalApi
from libs.token import generate_csrf_token
from machinery.errors import ActiveWorkspaceRequiredError
from models import Account, AccountTrialAppRecord, Conversation, Message, Tenant, TrialApp
from models import Account, AccountTrialAppRecord, Tenant, TrialApp
from models import App as _trial_audio_App
from models import App as _trial_file_upload_App
from models import App as _trial_generation_App
from models import App as _trial_preview_App
from models import App as _trial_preview_details_App
from models import App as _trial_suggested_questions_App
from models import AppMode as _trial_audio_AppMode
from models import AppMode as _trial_file_upload_AppMode
from models import AppMode as _trial_generation_AppMode
from models import AppMode as _trial_preview_AppMode
from models import AppMode as _trial_preview_details_AppMode
from models import AppMode as _trial_suggested_questions_AppMode
from models import UploadFile as _trial_file_upload_UploadFile
from models.account import AccountStatus, TenantAccountRole, TenantStatus
from models.agent import (
@@ -105,7 +93,7 @@ from models.agent import (
)
from models.agent_config_entities import AgentSoulConfig
from models.dataset import Dataset
from models.enums import ConversationFromSource, CreatorUserRole, CustomizeTokenStrategy, TagType
from models.enums import CreatorUserRole, CustomizeTokenStrategy, TagType
from models.model import App as _trial_agent_config_App
from models.model import AppMode as _trial_agent_config_AppMode
from models.model import AppModelConfig, IconType, RecommendedApp, Site, Tag, TagBinding
@@ -139,12 +127,6 @@ from services.errors.conversation import ConversationCompletedError, Conversatio
from services.errors.llm import InvokeRateLimitError
from services.feature_service import FeatureService
from services.file_service import FileService
from services.message_suggested_questions_adapters import MessageSuggestedQuestionsRuntime
from services.message_suggested_questions_service import (
SuggestedQuestionsAccount,
SuggestedQuestionsActor,
SuggestedQuestionsActorNotFoundError,
)
from services.recommended_app_query_service import (
RecommendedAppCatalogPage,
RecommendedAppDetailRecord,
@@ -3492,583 +3474,3 @@ class TestTrialPreviewDetailsCases:
)
_trial_preview_details_assert_error(harness.get(endpoint), 400, "app_unavailable")
assert harness.io.decryptions == []
"""Trial suggested questions through HTTP, real ownership queries, and SQLite."""
@dataclass
class _TrialSuggestedQuestionsFeatures:
enabled: bool = True
setup_completed: bool = True
events: list[str] = field(default_factory=list)
def is_trial_enabled(self) -> bool:
self.events.append("feature")
return self.enabled
@dataclass
class _Provider:
read_sessions: list[Session]
app_id: str
model_sessions: list[Session] = field(default_factory=list)
token_calls: int = 0
tenant_ids: list[str] = field(default_factory=list)
prompts: list[str] = field(default_factory=list)
traces: list[TraceTask] = field(default_factory=list)
questions: list[str] = field(default_factory=lambda: ["Next question?", "Another question?"])
failure: Exception | None = None
missing_model: bool = False
app_type: str = "chatbot"
def get_default_model_instance(self, *, tenant_id: str, model_type: ModelType) -> ModelInstance:
assert model_type == ModelType.LLM
self.tenant_ids.append(tenant_id)
session = db.session()
assert session.get(_trial_suggested_questions_App, self.app_id) is not None
self.model_sessions.append(session)
if self.missing_model:
raise ProviderTokenNotInitError("No default model")
return cast(ModelInstance, self)
def assert_queries_closed(self) -> None:
assert all(
not session.in_transaction() and not session.identity_map
for session in self.read_sessions + self.model_sessions
)
def get_llm_num_tokens(self, prompt_messages: Sequence[PromptMessage]) -> int:
self.assert_queries_closed()
self.token_calls += 1
if self.failure is not None:
raise self.failure
return len(prompt_messages)
def get_model_schema(self) -> AIModelEntity:
self.assert_queries_closed()
return AIModelEntity.model_construct(parameter_rules=[])
def invoke_llm(
self,
*,
prompt_messages: list[PromptMessage],
model_parameters: Mapping[str, object],
stop: list[str],
stream: bool,
) -> LLMResult:
self.assert_queries_closed()
assert self.tenant_ids == [self.tenant_ids[0]] * 2
assert get_credit_usage_metadata() == {"app_type": self.app_type, "created_by": "suggested_questions"}
assert model_parameters == {"max_tokens": 256, "temperature": 0.0}
assert stop == []
assert stream is False
prompt = prompt_messages[0].get_text_content()
assert "Suggest concise follow-ups" in prompt
self.prompts.append(prompt)
return LLMResult(
model="question-model",
message=AssistantPromptMessage(content=json.dumps(self.questions)),
usage=LLMUsage.empty_usage(),
)
def add_trace_task(self, task: TraceTask) -> None:
self.traces.append(task)
@dataclass(frozen=True)
class _TrialSuggestedQuestionsTrialappservices:
access: TrialAppAccessService
@dataclass(frozen=True)
class _TrialSuggestedQuestionsApplicationservices:
trial_apps: _TrialSuggestedQuestionsTrialappservices
recommended_app_queries: _TrialSuggestedQuestionsFeatures
message_suggested_questions: MessageSuggestedQuestionsRuntime
@dataclass(frozen=True)
class _TrialSuggestedQuestionsHarness:
app: Flask
account: Account
target: _trial_suggested_questions_App
trial: TrialApp
conversation: Conversation
message: Message
config: AppModelConfig
factory: sessionmaker[Session]
features: _TrialSuggestedQuestionsFeatures
provider: _Provider
legacy_sessions: list[Session]
def get(self, *, app_id: str | None = None, message_id: str | None = None) -> TestResponse:
return self.app.test_client().get(
f"/trial-apps/{app_id or self.target.id}/messages/{message_id or self.message.id}/suggested-questions"
)
def assert_closed(self) -> None:
assert all(
not session.in_transaction() and not session.identity_map
for session in self.provider.read_sessions + self.legacy_sessions
)
def usage(self) -> int | None:
with self.factory() as session:
return session.scalar(
select(AccountTrialAppRecord.count).where(
AccountTrialAppRecord.app_id == self.target.id,
AccountTrialAppRecord.account_id == self.account.id,
)
)
def _trial_suggested_questions_assert_error(
response: TestResponse, status: int, code: str, message: str | None = None
) -> None:
assert response.status_code == status, response.get_json()
body = response.get_json()
assert body["code"] == code
assert body["status"] == status
assert body["message"]
if message is not None:
assert body["message"] == message
assert response.headers["Content-Type"] == "application/json"
class TestTrialSuggestedQuestionsCases:
@staticmethod
@pytest.fixture
def harness(
monkeypatch: pytest.MonkeyPatch,
config_overrides: Callable[..., None],
sqlite_session_factory: sessionmaker[Session],
sqlite_engine: Engine,
) -> Iterator[_TrialSuggestedQuestionsHarness]:
config_overrides(DEPLOYMENT_EDITION=DeploymentEdition.COMMUNITY, INIT_PASSWORD="", LOGIN_DISABLED=False)
features = _TrialSuggestedQuestionsFeatures()
account = Account(name="Trial viewer", email="trial@example.com")
account._current_tenant = Tenant(name="Viewer workspace")
target = _trial_suggested_questions_App(
tenant_id=str(uuid4()),
name="Trial app",
mode=_trial_suggested_questions_AppMode.CHAT,
enable_site=True,
enable_api=False,
)
target.id = str(uuid4())
trial = TrialApp(app_id=target.id, tenant_id=str(uuid4()), trial_limit=3)
config = AppModelConfig(
app_id=target.id,
suggested_questions_after_answer='{"enabled":true,"prompt":"Suggest concise follow-ups"}',
)
with sqlite_session_factory.begin() as session:
session.add_all([account, target, trial, config])
session.flush()
target.app_model_config_id = config.id
conversation = Conversation(
app_id=target.id,
app_model_config_id=config.id,
mode=_trial_suggested_questions_AppMode.CHAT,
name="Trial conversation",
_inputs={},
from_source=ConversationFromSource.CONSOLE,
from_account_id=account.id,
)
session.add(conversation)
session.flush()
message = Message(
app_id=target.id,
conversation_id=conversation.id,
_inputs={},
query="What is a trial?",
message={},
message_unit_price=Decimal(0),
answer="A way to try an app.",
answer_unit_price=Decimal(0),
currency="USD",
from_source=ConversationFromSource.CONSOLE,
from_account_id=account.id,
)
session.add(message)
read_sessions: list[Session] = []
read_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
@event.listens_for(read_factory, "after_begin")
def track_read(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
read_sessions.append(session)
provider = _Provider(read_sessions, target.id)
services = _TrialSuggestedQuestionsApplicationservices(
trial_apps=_TrialSuggestedQuestionsTrialappservices(
access=TrialAppAccessService(apps=TrialAppRepository(session_factory=read_factory))
),
recommended_app_queries=features,
message_suggested_questions=MessageSuggestedQuestionsRuntime(session_factory=read_factory),
)
def setup_completed() -> bool:
features.events.append("setup")
return features.setup_completed
def csrf(_request: Request, account_id: str) -> None:
assert account_id == account.id
features.events.append("csrf")
for module in (trial_module, _trial_suggested_questions_admission_module):
monkeypatch.setattr(module, "application_services", lambda: services)
monkeypatch.setattr(console_wraps, "_is_setup_completed", setup_completed)
monkeypatch.setattr(login_module, "current_user", account)
monkeypatch.setattr(login_module, "check_csrf_token", csrf)
def model_manager(*, tenant_id: str) -> _Provider:
assert tenant_id == target.tenant_id
return provider
def trace_manager(*, app_id: str) -> _Provider:
assert app_id == target.id
return provider
monkeypatch.setattr(message_module.ModelManager, "for_tenant", model_manager)
monkeypatch.setattr(message_module, "TraceQueueManager", trace_manager)
app = Flask(__name__)
app.config.update(TESTING=True, RESTX_ERROR_404_HELP=False, SQLALCHEMY_DATABASE_URI=str(sqlite_engine.url))
db.init_app(app)
legacy_sessions: list[Session] = []
def track_legacy(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
legacy_sessions.append(session)
event.listen(db.session.session_factory, "after_begin", track_legacy)
login_manager = DifyLoginManager()
login_manager.init_app(app)
login_manager.unauthorized_handler(unauthorized_handler)
api = ExternalApi(app)
api.add_resource(
trial_module.TrialMessageSuggestedQuestionApi,
"/trial-apps/<uuid:app_id>/messages/<uuid:message_id>/suggested-questions",
)
yield _TrialSuggestedQuestionsHarness(
app,
account,
target,
trial,
conversation,
message,
config,
sqlite_session_factory,
features,
provider,
legacy_sessions,
)
event.remove(db.session.session_factory, "after_begin", track_legacy)
with app.app_context():
db.session.remove()
db.engine.dispose()
@staticmethod
@pytest.mark.parametrize(
"mode",
[
_trial_suggested_questions_AppMode.CHAT,
_trial_suggested_questions_AppMode.AGENT_CHAT,
_trial_suggested_questions_AppMode.AGENT,
],
)
@pytest.mark.parametrize("questions", [["Next question?", "Another question?"], []])
def test_questions_keep_response_history_owner_and_usage(
harness: _TrialSuggestedQuestionsHarness, questions: list[str], mode: _trial_suggested_questions_AppMode
) -> None:
harness.provider.questions = questions
harness.provider.app_type = {
_trial_suggested_questions_AppMode.CHAT: "chatbot",
_trial_suggested_questions_AppMode.AGENT_CHAT: "agent",
_trial_suggested_questions_AppMode.AGENT: "agent_v2",
}[mode]
with harness.factory.begin() as session:
session.execute(
update(_trial_suggested_questions_App)
.where(_trial_suggested_questions_App.id == harness.target.id)
.values(mode=mode)
)
session.execute(update(Conversation).where(Conversation.id == harness.conversation.id).values(mode=mode))
with harness.factory.begin() as session:
session.add_all(
[
AccountTrialAppRecord(app_id=harness.target.id, account_id=harness.account.id, count=1),
AccountTrialAppRecord(app_id=harness.target.id, account_id=str(uuid4()), count=3),
]
)
response = harness.get()
assert response.status_code == 200, response.get_json()
assert response.get_json() == {"data": questions}
assert response.headers["Content-Type"] == "application/json"
assert int(response.headers["Content-Length"]) == len(response.data)
assert len(harness.provider.prompts) == 1
assert "Human: What is a trial?\nAssistant: A way to try an app." in harness.provider.prompts[0]
assert harness.provider.tenant_ids == [harness.target.tenant_id] * 2
assert harness.target.tenant_id not in {harness.trial.tenant_id, harness.account.current_tenant_id}
assert len(harness.provider.traces) == 1
assert harness.features.events == ["setup", "csrf", "feature"]
assert harness.usage() == 1
assert len(harness.provider.model_sessions) == 2
harness.assert_closed()
@staticmethod
@pytest.mark.parametrize("entity", ["message", "conversation"])
@pytest.mark.parametrize("mismatch", ["missing", "app", "account", "source", "end-user"])
def test_message_and_conversation_require_complete_owner_chain(
harness: _TrialSuggestedQuestionsHarness, entity: str, mismatch: str
) -> None:
message_id = harness.message.id
with harness.factory.begin() as session:
record = (
session.get(Message, harness.message.id)
if entity == "message"
else session.get(Conversation, harness.conversation.id)
)
assert record is not None
match mismatch:
case "missing":
if entity == "message":
message_id = str(uuid4())
else:
session.delete(record)
case "app":
record.app_id = str(uuid4())
case "account":
record.from_account_id = str(uuid4())
case "source":
record.from_source = ConversationFromSource.API
case "end-user":
record.from_end_user_id = str(uuid4())
response = harness.get(message_id=message_id)
_trial_suggested_questions_assert_error(
response, 404, "not_found", "Message not found" if entity == "message" else "Conversation not found"
)
assert harness.provider.prompts == []
assert harness.usage() is None
harness.assert_closed()
@staticmethod
def test_deleted_conversation_is_not_visible(harness: _TrialSuggestedQuestionsHarness) -> None:
with harness.factory.begin() as session:
session.execute(
update(Conversation).where(Conversation.id == harness.conversation.id).values(is_deleted=True)
)
_trial_suggested_questions_assert_error(harness.get(), 404, "not_found", "Conversation not found")
assert harness.provider.prompts == []
harness.assert_closed()
@staticmethod
@pytest.mark.parametrize("config", [None, '{"enabled":false}'])
def test_disabled_or_unset_suggestions_have_specific_error(
harness: _TrialSuggestedQuestionsHarness, config: str | None
) -> None:
with harness.factory.begin() as session:
session.execute(
update(AppModelConfig)
.where(AppModelConfig.id == harness.config.id)
.values(suggested_questions_after_answer=config)
)
_trial_suggested_questions_assert_error(harness.get(), 403, "app_suggested_questions_after_answer_disabled")
assert harness.provider.prompts == []
harness.assert_closed()
@staticmethod
def test_absent_default_model_keeps_empty_success(harness: _TrialSuggestedQuestionsHarness) -> None:
harness.provider.missing_model = True
response = harness.get()
assert response.status_code == 200
assert response.get_json() == {"data": []}
assert harness.provider.prompts == []
assert harness.provider.traces == []
harness.assert_closed()
@staticmethod
@pytest.mark.parametrize(
"mode", [_trial_suggested_questions_AppMode.COMPLETION, _trial_suggested_questions_AppMode.WORKFLOW]
)
def test_unsupported_app_modes_fail_before_runtime(
harness: _TrialSuggestedQuestionsHarness, mode: _trial_suggested_questions_AppMode
) -> None:
with harness.factory.begin() as session:
session.execute(
update(_trial_suggested_questions_App)
.where(_trial_suggested_questions_App.id == harness.target.id)
.values(mode=mode)
)
_trial_suggested_questions_assert_error(harness.get(), 400, "not_chat_app")
assert len(harness.provider.read_sessions) == 1
assert harness.legacy_sessions == []
harness.assert_closed()
@staticmethod
@pytest.mark.parametrize(
("denial", "status", "code"),
[
("setup", 401, "not_setup"),
("feature", 403, "trial_app_feature_disabled"),
("missing_trial", 403, "trial_app_not_allowed"),
("missing_app", 403, "trial_app_not_allowed"),
("quota", 403, "trial_app_limit_exceeded"),
("uninitialized", 400, "account_not_initialized"),
("unauthenticated", 401, "unauthorized"),
],
)
def test_admission_blocks_before_runtime(
harness: _TrialSuggestedQuestionsHarness, monkeypatch: pytest.MonkeyPatch, denial: str, status: int, code: str
) -> None:
if denial == "setup":
harness.features.setup_completed = False
elif denial == "feature":
harness.features.enabled = False
elif denial == "uninitialized":
harness.account.status = AccountStatus.UNINITIALIZED
elif denial == "unauthenticated":
monkeypatch.setattr(login_module, "current_user", None)
else:
with harness.factory.begin() as session:
if denial == "missing_trial":
session.execute(delete(TrialApp).where(TrialApp.app_id == harness.target.id))
elif denial == "missing_app":
session.execute(
delete(_trial_suggested_questions_App).where(
_trial_suggested_questions_App.id == harness.target.id
)
)
else:
session.add(AccountTrialAppRecord(app_id=harness.target.id, account_id=harness.account.id, count=3))
response = harness.get()
if denial == "unauthenticated":
assert response.status_code == 401
assert response.get_json() == {"code": "unauthorized", "message": "Unauthorized.", "status": 401}
else:
_trial_suggested_questions_assert_error(response, status, code)
assert harness.legacy_sessions == []
assert harness.provider.prompts == []
harness.assert_closed()
@staticmethod
@pytest.mark.parametrize(
("failure", "code", "message"),
[
(
ProviderTokenNotInitError("Missing tenant credentials"),
"provider_not_initialize",
"Missing tenant credentials",
),
(QuotaExceededError(), "provider_quota_exceeded", None),
(ModelCurrentlyNotSupportError(), "model_currently_not_support", None),
(InvokeError("Provider unavailable"), "completion_request_error", "Provider unavailable"),
],
)
def test_token_count_failures_preserve_specific_errors_and_close_session(
harness: _TrialSuggestedQuestionsHarness, failure: Exception, code: str, message: str | None
) -> None:
harness.provider.failure = failure
_trial_suggested_questions_assert_error(harness.get(), 400, code, message)
assert harness.provider.token_calls == 1
assert harness.provider.prompts == []
assert harness.provider.traces == []
assert harness.usage() is None
harness.assert_closed()
@staticmethod
@pytest.mark.parametrize("failure", [None, InvokeError("Provider unavailable")])
def test_runtime_preserves_outer_request_session(
harness: _TrialSuggestedQuestionsHarness, failure: Exception | None
) -> None:
harness.provider.failure = failure
with harness.app.app_context():
outer_session = db.session()
app_model = outer_session.get(_trial_suggested_questions_App, harness.target.id)
assert app_model is not None
app_model.name = "Pending caller change"
response = harness.get()
assert response.status_code == (200 if failure is None else 400)
assert db.session() is outer_session
assert outer_session.in_transaction()
assert app_model in outer_session.dirty
assert app_model.name == "Pending caller change"
assert harness.provider.model_sessions
for inner_session in harness.provider.model_sessions:
assert inner_session is not outer_session
assert not inner_session.in_transaction()
assert not inner_session.identity_map
harness.assert_closed()
with harness.factory() as session:
saved_app = session.get(_trial_suggested_questions_App, harness.target.id)
assert saved_app is not None
assert saved_app.name == "Trial app"
@staticmethod
def test_advanced_chat_without_published_workflow_keeps_empty_success(
harness: _TrialSuggestedQuestionsHarness,
) -> None:
with harness.factory.begin() as session:
session.execute(
update(_trial_suggested_questions_App)
.where(_trial_suggested_questions_App.id == harness.target.id)
.values(mode=_trial_suggested_questions_AppMode.ADVANCED_CHAT)
)
response = harness.get()
assert response.status_code == 200
assert response.get_json() == {"data": []}
assert len(harness.provider.read_sessions) == 2
assert harness.provider.prompts == []
assert harness.usage() is None
harness.assert_closed()
@staticmethod
@pytest.mark.parametrize(
("failure", "status", "code", "message"),
[
(AppDefinitionUnavailableError("App changed after admission"), 400, "app_unavailable", None),
(
SuggestedQuestionsActorNotFoundError("Account disappeared"),
401,
"unauthorized",
"Account no longer exists.",
),
],
)
def test_reload_errors_have_explicit_http_mapping(
harness: _TrialSuggestedQuestionsHarness,
monkeypatch: pytest.MonkeyPatch,
failure: Exception,
status: int,
code: str,
message: str | None,
) -> None:
def reject_reload(
_self: MessageSuggestedQuestionsRuntime,
*,
app_id: str,
app_owner_tenant_id: str,
expected_app_mode: str,
actor: SuggestedQuestionsActor,
message_id: str,
) -> list[str]:
assert (app_id, app_owner_tenant_id, expected_app_mode, message_id) == (
harness.target.id,
harness.target.tenant_id,
harness.target.mode,
harness.message.id,
)
assert actor == SuggestedQuestionsAccount(account_id=harness.account.id, invoke_from="explore")
raise failure
monkeypatch.setattr(MessageSuggestedQuestionsRuntime, "get_suggested_questions", reject_reload)
_trial_suggested_questions_assert_error(harness.get(), status, code, message)
assert harness.provider.prompts == []
assert harness.usage() is None
harness.assert_closed()
@@ -23,8 +23,8 @@ import pytest
from flask import Flask, request
from sqlalchemy import Engine
from sqlalchemy.orm import Session
from werkzeug.exceptions import BadRequest, InternalServerError, NotFound
from controllers.common.errors import NotFoundError
from controllers.service_api.app.error import NotChatAppError
from controllers.service_api.app.message import (
AppGetFeedbacksApi,
@@ -33,7 +33,6 @@ from controllers.service_api.app.message import (
MessageFeedbackPayload,
MessageListApi,
MessageListQuery,
MessageSuggestedApi,
)
from models.enums import EndUserType, FeedbackRating
from models.model import App, AppMode, EndUser
@@ -282,11 +281,6 @@ class TestMessageService:
assert hasattr(MessageService, "get_all_messages_feedbacks")
assert callable(MessageService.get_all_messages_feedbacks)
def test_get_suggested_questions_after_answer_method_exists(self):
"""Test MessageService.get_suggested_questions_after_answer exists."""
assert hasattr(MessageService, "get_suggested_questions_after_answer")
assert callable(MessageService.get_suggested_questions_after_answer)
@patch.object(MessageService, "pagination_by_first_id")
def test_pagination_by_first_id_returns_pagination_result(self, mock_pagination, orm_session: Session):
"""Test pagination_by_first_id returns expected format."""
@@ -386,51 +380,6 @@ class TestMessageService:
assert len(result) == 2
assert result[0]["rating"] == "like"
@patch.object(MessageService, "get_suggested_questions_after_answer")
def test_get_suggested_questions_returns_questions_list(self, mock_get_questions, orm_session: Session):
"""Test get_suggested_questions_after_answer returns list of questions."""
mock_questions = ["What about this aspect?", "Can you elaborate on that?", "How does this relate to...?"]
mock_get_questions.return_value = mock_questions
result = MessageService.get_suggested_questions_after_answer(
app_model=_app(),
user=_end_user(),
message_id=str(uuid.uuid4()),
invoke_from=Mock(),
session=orm_session,
)
assert len(result) == 3
assert isinstance(result[0], str)
@patch.object(MessageService, "get_suggested_questions_after_answer")
def test_get_suggested_questions_raises_disabled_error(self, mock_get_questions, orm_session: Session):
"""Test get_suggested_questions_after_answer raises SuggestedQuestionsAfterAnswerDisabledError."""
mock_get_questions.side_effect = SuggestedQuestionsAfterAnswerDisabledError()
with pytest.raises(SuggestedQuestionsAfterAnswerDisabledError):
MessageService.get_suggested_questions_after_answer(
app_model=_app(),
user=_end_user(),
message_id=str(uuid.uuid4()),
invoke_from=Mock(),
session=orm_session,
)
@patch.object(MessageService, "get_suggested_questions_after_answer")
def test_get_suggested_questions_raises_message_not_exists_error(self, mock_get_questions, orm_session: Session):
"""Test get_suggested_questions_after_answer raises MessageNotExistsError."""
mock_get_questions.side_effect = MessageNotExistsError()
with pytest.raises(MessageNotExistsError):
MessageService.get_suggested_questions_after_answer(
app_model=_app(),
user=_end_user(),
message_id="invalid_message_id",
invoke_from=Mock(),
session=orm_session,
)
class TestMessageListApi:
def test_not_chat_app(self, app: Flask) -> None:
@@ -466,7 +415,7 @@ class TestMessageListApi:
"/messages?conversation_id=00000000-0000-0000-0000-000000000001",
method="GET",
):
with pytest.raises(NotFound):
with pytest.raises(NotFoundError):
handler(
api,
MessageListQuery.model_validate(request.args.to_dict(flat=True)),
@@ -490,7 +439,7 @@ class TestMessageListApi:
"/messages?conversation_id=00000000-0000-0000-0000-000000000001&first_id=00000000-0000-0000-0000-000000000002",
method="GET",
):
with pytest.raises(NotFound):
with pytest.raises(NotFoundError):
handler(
api,
MessageListQuery.model_validate(request.args.to_dict(flat=True)),
@@ -518,7 +467,7 @@ class TestMessageFeedbackApi:
json={"rating": "like", "content": "ok"},
):
payload = MessageFeedbackPayload.model_validate(request.get_json() or {})
with pytest.raises(NotFound):
with pytest.raises(NotFoundError):
handler(api, payload, app_model=app_model, end_user=end_user, message_id="m1")
@@ -549,80 +498,3 @@ class TestAppGetFeedbacksApi:
)
assert response == {"data": [feedback]}
class TestMessageSuggestedApi:
def test_not_chat(self, app: Flask) -> None:
api = MessageSuggestedApi()
handler = unwrap(api.get)
app_model = _app(mode=AppMode.COMPLETION)
end_user = _end_user()
with app.test_request_context("/messages/m1/suggested", method="GET"):
with pytest.raises(NotChatAppError):
handler(api, app_model=app_model, end_user=end_user, message_id="m1")
def test_not_found(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
MessageService,
"get_suggested_questions_after_answer",
lambda *_args, **_kwargs: (_ for _ in ()).throw(MessageNotExistsError()),
)
api = MessageSuggestedApi()
handler = unwrap(api.get)
app_model = _app()
end_user = _end_user()
with app.test_request_context("/messages/m1/suggested", method="GET"):
with pytest.raises(NotFound):
handler(api, app_model=app_model, end_user=end_user, message_id="m1")
def test_disabled(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
MessageService,
"get_suggested_questions_after_answer",
lambda *_args, **_kwargs: (_ for _ in ()).throw(SuggestedQuestionsAfterAnswerDisabledError()),
)
api = MessageSuggestedApi()
handler = unwrap(api.get)
app_model = _app()
end_user = _end_user()
with app.test_request_context("/messages/m1/suggested", method="GET"):
with pytest.raises(BadRequest):
handler(api, app_model=app_model, end_user=end_user, message_id="m1")
def test_internal_error(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
MessageService,
"get_suggested_questions_after_answer",
lambda *_args, **_kwargs: (_ for _ in ()).throw(RuntimeError("boom")),
)
api = MessageSuggestedApi()
handler = unwrap(api.get)
app_model = _app()
end_user = _end_user()
with app.test_request_context("/messages/m1/suggested", method="GET"):
with pytest.raises(InternalServerError):
handler(api, app_model=app_model, end_user=end_user, message_id="m1")
def test_success(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(
MessageService,
"get_suggested_questions_after_answer",
lambda *_args, **_kwargs: ["q1"],
)
api = MessageSuggestedApi()
handler = unwrap(api.get)
app_model = _app()
end_user = _end_user()
with app.test_request_context("/messages/m1/suggested", method="GET"):
response = handler(api, app_model=app_model, end_user=end_user, message_id="m1")
assert response == {"result": "success", "data": ["q1"]}
@@ -13,19 +13,17 @@ from uuid import uuid4
import pytest
from flask import Flask
from flask.views import MethodView
from werkzeug.exceptions import NotFound
from controllers.common.controller_schemas import MessageFeedbackPayload, MessageListQuery
from controllers.common.errors import NotFoundError
from controllers.web.error import (
AppMoreLikeThisDisabledError,
NotChatAppError,
NotCompletionAppError,
)
from controllers.web.message import (
MessageFeedbackApi,
MessageMoreLikeThisApi,
MessageMoreLikeThisQuery,
MessageSuggestedQuestionApi,
)
from core.app.entities.app_invoke_entities import InvokeFrom
from core.entities.execution_extra_content import HumanInputContent
@@ -100,7 +98,7 @@ class TestMessageEndpointsCases:
msg_id = uuid4()
with app.test_request_context(f"/messages/{msg_id}/feedbacks", method="POST"):
with pytest.raises(NotFound, match="Message Not Exists"):
with pytest.raises(NotFoundError, match="Message Not Exists"):
_feedback_post(MessageFeedbackApi(), payload, _chat_app(), _end_user(), msg_id)
# ---------------------------------------------------------------------------
@@ -139,7 +137,7 @@ class TestMessageEndpointsCases:
query = MessageMoreLikeThisQuery.model_validate({"response_mode": "blocking"})
session = MagicMock()
with app.test_request_context(f"/messages/{msg_id}/more-like-this?response_mode=blocking"):
with pytest.raises(NotFound, match="Message Not Exists"):
with pytest.raises(NotFoundError, match="Message Not Exists"):
_more_like_this_get(
MessageMoreLikeThisApi(), query, session, _completion_app(), _end_user(), msg_id
)
@@ -161,32 +159,6 @@ class TestMessageEndpointsCases:
# ---------------------------------------------------------------------------
# MessageSuggestedQuestionApi
# ---------------------------------------------------------------------------
class TestMessageSuggestedQuestionApi:
def test_wrong_mode_raises(self, app: Flask) -> None:
msg_id = uuid4()
with app.test_request_context(f"/messages/{msg_id}/suggested-questions"):
with pytest.raises(NotChatAppError):
MessageSuggestedQuestionApi().get(_completion_app(), _end_user(), msg_id)
@patch("controllers.web.message.MessageService.get_suggested_questions_after_answer")
def test_happy_path(self, mock_suggest: MagicMock, app: Flask) -> None:
msg_id = uuid4()
mock_suggest.return_value = ["What about X?", "Tell me more about Y."]
with app.test_request_context(f"/messages/{msg_id}/suggested-questions"):
result = MessageSuggestedQuestionApi().get(_chat_app(), _end_user(), msg_id)
assert result["data"] == ["What about X?", "Tell me more about Y."]
@patch(
"controllers.web.message.MessageService.get_suggested_questions_after_answer",
side_effect=MessageNotExistsError(),
)
def test_message_not_found(self, mock_suggest: MagicMock, app: Flask) -> None:
msg_id = uuid4()
with app.test_request_context(f"/messages/{msg_id}/suggested-questions"):
with pytest.raises(NotFound, match="Message not found"):
MessageSuggestedQuestionApi().get(_chat_app(), _end_user(), msg_id)
"""Unit tests for controllers.web.message message list mapping."""
@@ -2,7 +2,6 @@ import json
from collections.abc import Iterator
from datetime import datetime, timedelta
from decimal import Decimal
from types import SimpleNamespace
from unittest.mock import MagicMock, Mock, patch
from uuid import uuid4
@@ -13,21 +12,17 @@ from sqlalchemy.orm import Session, scoped_session, sessionmaker
import core.llm_generator.llm_generator as generator_module
from core.app.app_config.entities import ModelConfig
from core.app.entities.app_invoke_entities import CreditUsageCreatedBy
from core.llm_generator import llm_generator as llm_generator_module
from core.llm_generator.entities import RuleCodeGeneratePayload, RuleGeneratePayload, RuleStructuredOutputPayload
from core.llm_generator.llm_generator import LLMGenerator, _parse_string_list
from core.model_context import get_credit_usage_metadata
from core.model_manager import ModelManager
from core.plugin.impl.base import _get_plugin_daemon_request_timeout
from core.plugin.impl.model_runtime_factory import create_plugin_model_manager
from core.workflow.generator import tool_catalogue as tool_catalogue_module
from core.workflow.generator.tool_catalogue import ToolCatalogueEntry
from graphon.enums import WorkflowNodeExecutionStatus
from graphon.model_runtime.entities.llm_entities import LLMMode, LLMResult, LLMUsage
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage
from graphon.model_runtime.entities.model_entities import ModelType, ParameterType
from graphon.model_runtime.errors.invoke import InvokeAuthorizationError, InvokeError
from graphon.model_runtime.errors.invoke import InvokeError
from models.dataset import Dataset
from models.enums import ConversationFromSource, CreatorUserRole
from models.model import App, AppMode, Message
@@ -37,7 +32,7 @@ from models.workflow import (
WorkflowNodeExecutionTriggeredFrom,
)
from services.workflow_service import WorkflowService
from tests.helpers.unit.core.model_fixtures import make_model_config, make_model_instance
from tests.helpers.unit.core.model_fixtures import make_model_instance
from tests.helpers.unit.model_factories import make_dataset
"""Tests for LLM generation and database-backed instruction modification."""
@@ -263,283 +258,6 @@ class TestLlmGeneratorCases:
assert len(name) == 78 # 75 + "..."
assert name.endswith("...")
def test_generate_suggested_questions_after_answer_success(self, mock_model_instance):
mock_response = MagicMock()
mock_response.message.get_text_content.return_value = '["Question 1?", "Question 2?"]'
mock_model_instance.invoke_llm.return_value = mock_response
questions = LLMGenerator.generate_suggested_questions_after_answer("tenant_id", "histories")
assert len(questions) == 2
assert questions[0] == "Question 1?"
assert mock_model_instance.invoke_llm.call_args.kwargs["model_parameters"] == {
"max_tokens": 256,
"temperature": 0.0,
}
def test_generate_suggested_questions_after_answer_uses_lowest_reasoning_effort(self, mock_model_instance):
mock_response = MagicMock()
mock_response.message.get_text_content.return_value = '["Question 1?"]'
mock_model_instance.invoke_llm.return_value = mock_response
mock_model_instance.get_model_schema.return_value.parameter_rules = [
SimpleNamespace(
name="reasoning_effort",
type=ParameterType.STRING,
options=["minimal", "low", "medium", "high"],
)
]
questions = LLMGenerator.generate_suggested_questions_after_answer("tenant_id", "histories")
assert questions == ["Question 1?"]
assert mock_model_instance.invoke_llm.call_args.kwargs["model_parameters"] == {
"max_tokens": 256,
"temperature": 0.0,
"reasoning_effort": "minimal",
}
def test_generate_suggested_questions_after_answer_uses_defaults_when_schema_lookup_fails(
self, mock_model_instance
):
mock_response = MagicMock()
mock_response.message.get_text_content.return_value = '["Question 1?"]'
mock_model_instance.invoke_llm.return_value = mock_response
mock_model_instance.get_model_schema.side_effect = ValueError("schema unavailable")
questions = LLMGenerator.generate_suggested_questions_after_answer("tenant_id", "histories")
assert questions == ["Question 1?"]
assert mock_model_instance.invoke_llm.call_args.kwargs["model_parameters"] == {
"max_tokens": 256,
"temperature": 0.0,
}
@pytest.mark.parametrize(
("parameter_type", "options", "expected_value"),
[
(ParameterType.BOOLEAN, [], False),
(ParameterType.STRING, ["enabled", "disabled"], "disabled"),
],
)
def test_generate_suggested_questions_after_answer_disables_thinking(
self, mock_model_instance, parameter_type, options, expected_value
):
mock_response = MagicMock()
mock_response.message.get_text_content.return_value = '["Question 1?"]'
mock_model_instance.invoke_llm.return_value = mock_response
mock_model_instance.get_model_schema.return_value.parameter_rules = [
SimpleNamespace(name="thinking", type=parameter_type, options=options),
SimpleNamespace(name="reasoning_effort", type=ParameterType.STRING, options=["low", "high"]),
]
questions = LLMGenerator.generate_suggested_questions_after_answer("tenant_id", "histories")
assert questions == ["Question 1?"]
assert mock_model_instance.invoke_llm.call_args.kwargs["model_parameters"] == {
"max_tokens": 256,
"temperature": 0.0,
"thinking": expected_value,
}
def test_generate_suggested_questions_after_answer_auth_error(self, mock_model_instance):
with patch("core.llm_generator.llm_generator.ModelManager.for_tenant") as mock_manager:
mock_manager.return_value.get_default_model_instance.side_effect = InvokeAuthorizationError(
"Auth failed"
)
questions = LLMGenerator.generate_suggested_questions_after_answer("tenant_id", "histories")
assert questions == []
def test_generate_suggested_questions_after_answer_model_resolution_error(self, mock_model_instance):
with patch("core.llm_generator.llm_generator.ModelManager.for_tenant") as mock_manager:
mock_manager.return_value.get_default_model_instance.side_effect = ValueError(
"unsupported default model"
)
questions = LLMGenerator.generate_suggested_questions_after_answer("tenant_id", "histories")
assert questions == []
def test_generate_suggested_questions_after_answer_invoke_error(self, mock_model_instance):
mock_model_instance.invoke_llm.side_effect = InvokeError("Invoke failed")
questions = LLMGenerator.generate_suggested_questions_after_answer("tenant_id", "histories")
assert questions == []
def test_generate_suggested_questions_after_answer_exception(self, mock_model_instance):
mock_model_instance.invoke_llm.side_effect = Exception("Random error")
questions = LLMGenerator.generate_suggested_questions_after_answer("tenant_id", "histories")
assert questions == []
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
def test_generate_suggested_questions_after_answer_with_custom_model_and_prompt(self, mock_for_tenant):
custom_model_instance = MagicMock()
custom_response = MagicMock()
custom_response.message.get_text_content.return_value = '["Question 1?"]'
custom_model_instance.invoke_llm.return_value = custom_response
mock_for_tenant.return_value.get_model_instance.return_value = custom_model_instance
questions = LLMGenerator.generate_suggested_questions_after_answer(
"tenant_id",
"histories",
instruction_prompt="custom prompt",
model_config={
"provider": "openai",
"name": "gpt-4o",
"completion_params": {"temperature": 0.2},
},
)
assert questions == ["Question 1?"]
mock_for_tenant.return_value.get_model_instance.assert_called_once_with(
tenant_id="tenant_id",
model_type=ModelType.LLM,
provider="openai",
model="gpt-4o",
)
invoke_kwargs = custom_model_instance.invoke_llm.call_args.kwargs
assert invoke_kwargs["model_parameters"] == {"temperature": 0.2}
assert invoke_kwargs["stop"] == []
assert "custom prompt" in invoke_kwargs["prompt_messages"][0].content
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
def test_generate_suggested_questions_after_answer_with_custom_model_without_completion_params(
self, mock_for_tenant
):
custom_model_instance = MagicMock()
custom_response = MagicMock()
custom_response.message.get_text_content.return_value = '["Question 1?"]'
custom_model_instance.invoke_llm.return_value = custom_response
mock_for_tenant.return_value.get_model_instance.return_value = custom_model_instance
questions = LLMGenerator.generate_suggested_questions_after_answer(
"tenant_id",
"histories",
model_config={"provider": "openai", "name": "gpt-4o"},
)
assert questions == ["Question 1?"]
invoke_kwargs = custom_model_instance.invoke_llm.call_args.kwargs
assert invoke_kwargs["model_parameters"] == {}
assert invoke_kwargs["stop"] == []
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
def test_generate_suggested_questions_after_answer_fallback_to_default_model(self, mock_for_tenant):
default_model_instance = MagicMock()
default_response = MagicMock()
default_response.message.get_text_content.return_value = '["Question 1?"]'
default_model_instance.invoke_llm.return_value = default_response
mock_for_tenant.return_value.get_model_instance.side_effect = ValueError("invalid configured model")
mock_for_tenant.return_value.get_default_model_instance.return_value = default_model_instance
questions = LLMGenerator.generate_suggested_questions_after_answer(
"tenant_id",
"histories",
model_config={
"provider": "openai",
"name": "not-found-model",
"completion_params": {"temperature": 0.2},
},
)
assert questions == ["Question 1?"]
mock_for_tenant.return_value.get_default_model_instance.assert_called_once_with(
tenant_id="tenant_id",
model_type=ModelType.LLM,
)
assert default_model_instance.invoke_llm.call_args.kwargs["model_parameters"] == {
"max_tokens": 256,
"temperature": 0.0,
}
assert default_model_instance.invoke_llm.call_args.kwargs["stop"] == []
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
def test_generate_suggested_questions_after_answer_drops_non_positive_max_tokens(self, mock_for_tenant):
custom_model_instance = MagicMock()
custom_response = MagicMock()
custom_response.message.get_text_content.return_value = '["Question 1?"]'
custom_model_instance.invoke_llm.return_value = custom_response
mock_for_tenant.return_value.get_model_instance.return_value = custom_model_instance
questions = LLMGenerator.generate_suggested_questions_after_answer(
"tenant_id",
"histories",
model_config={
"provider": "openai",
"name": "gpt-4o",
"completion_params": {
"temperature": 0.2,
"max_tokens": 0,
"stop": ["END"],
},
},
)
assert questions == ["Question 1?"]
invoke_kwargs = custom_model_instance.invoke_llm.call_args.kwargs
assert invoke_kwargs["model_parameters"] == {"temperature": 0.2}
assert invoke_kwargs["stop"] == ["END"]
@pytest.mark.parametrize("use_configured_model", [False, True])
def test_prepared_suggested_questions_defer_provider_calls_without_resolving_again(
self, monkeypatch: pytest.MonkeyPatch, use_configured_model: bool
) -> None:
model_instance = make_model_instance(provider="openai", model="custom-model")
schema = make_model_config(provider="openai", model="custom-model", mode="chat").model_schema
get_schema = Mock(return_value=schema)
invocation = Mock()
monkeypatch.setattr(model_instance, "get_model_schema", get_schema)
monkeypatch.setattr(model_instance, "invoke_llm", invocation)
manager = create_plugin_model_manager(tenant_id="tenant_id")
monkeypatch.setattr(manager, "get_model_instance", Mock(return_value=model_instance))
monkeypatch.setattr(manager, "get_default_model_instance", Mock(return_value=model_instance))
resolve_manager = Mock(return_value=manager)
monkeypatch.setattr(ModelManager, "for_tenant", resolve_manager)
original_metadata = get_credit_usage_metadata()
original_timeout = _get_plugin_daemon_request_timeout()
def invoke(**_kwargs: object) -> LLMResult:
metadata = get_credit_usage_metadata()
assert metadata is not None
assert metadata["created_by"] == CreditUsageCreatedBy.SUGGESTED_QUESTIONS
timeout = _get_plugin_daemon_request_timeout()
assert timeout is not None
assert timeout.read == 30.0
return _llm_result('["Next question?"]')
invocation.side_effect = invoke
model_config = (
{
"provider": "openai",
"name": "custom-model",
"completion_params": {"temperature": 0.2, "stop": ["END"], "max_tokens": 0},
}
if use_configured_model
else None
)
prepared_model = LLMGenerator.prepare_suggested_questions_model("tenant_id", model_config=model_config)
assert prepared_model is not None
get_schema.assert_not_called()
invocation.assert_not_called()
resolve_manager.side_effect = AssertionError("Model lookup must finish in the preparation phase")
result = LLMGenerator.invoke_suggested_questions_after_answer(
prepared_model, "Human: hello\nAssistant: world", instruction_prompt="Ask a follow-up"
)
assert result == ["Next question?"]
parameters = invocation.call_args.kwargs
assert parameters["model_parameters"] == (
{"temperature": 0.2} if use_configured_model else {"max_tokens": 256, "temperature": 0.0}
)
assert parameters["stop"] == (["END"] if use_configured_model else [])
assert parameters["stream"] is False
assert "Human: hello\nAssistant: world" in parameters["prompt_messages"][0].content
assert "Ask a follow-up" in parameters["prompt_messages"][0].content
assert get_credit_usage_metadata() == original_metadata
assert _get_plugin_daemon_request_timeout() == original_timeout
def test_generate_rule_config_no_variable_success(self, mock_model_instance, model_config_entity):
payload = RuleGeneratePayload(
instruction="test instruction", model_config=model_config_entity, no_variable=True
@@ -2086,3 +2086,47 @@ class TestWorkflowRunAccessorsCases:
assert payload["limit"] == 20
assert payload["has_more"] is False
assert [item.created_by_account.id for item in payload["data"]] == [account.id, account.id]
@pytest.mark.parametrize(
("features", "expected"),
[
pytest.param({}, {}, id="missing-upload"),
pytest.param({"file_upload": None}, {"file_upload": None}, id="null-upload"),
pytest.param({"file_upload": False}, {"file_upload": False}, id="non-object-upload"),
pytest.param({"file_upload": {}}, {"file_upload": {}}, id="missing-image"),
pytest.param({"file_upload": {"image": None}}, {"file_upload": {"image": None}}, id="null-image"),
pytest.param({"file_upload": {"image": False}}, {"file_upload": {"image": False}}, id="non-object-image"),
pytest.param(
{"file_upload": {"enabled": False, "image": {"enabled": False}}},
{"file_upload": {"enabled": False, "image": {"enabled": False}}},
id="disabled-image",
),
pytest.param(OLD_VERSION_WORKFLOW_FEATURES, NEW_VERSION_WORKFLOW_FEATURES, id="legacy-image"),
pytest.param(NEW_VERSION_WORKFLOW_FEATURES, NEW_VERSION_WORKFLOW_FEATURES, id="modern-upload"),
],
)
def test_normalized_features_preserves_stored_payload_without_dirtying_workflow(
sqlite_session: Session,
features: dict[str, JsonValue],
expected: dict[str, JsonValue],
) -> None:
stored_features = json.dumps(features)
workflow = _workflow_feature_compatibility_Workflow(
tenant_id=str(uuid4()),
app_id=str(uuid4()),
type=WorkflowType.CHAT,
version="published",
graph="{}",
features=stored_features,
created_by=str(uuid4()),
)
sqlite_session.add(workflow)
sqlite_session.commit()
assert workflow.normalized_features_dict == expected
assert workflow.serialized_features == stored_features
assert workflow not in sqlite_session.dirty
sqlite_session.expire(workflow)
assert workflow.serialized_features == stored_features
@@ -1,4 +1,3 @@
import json
from collections.abc import Iterator
from dataclasses import dataclass
from datetime import datetime
@@ -11,18 +10,7 @@ from sqlalchemy.orm import Session, scoped_session
import models.model as model_module
import services.message_service as service_module
from core.app.entities.app_invoke_entities import InvokeFrom
from graphon.model_runtime.entities.model_entities import ModelType
from models.account import Account, AccountStatus
from models.agent import (
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigSnapshot,
AgentConfigVersionKind,
AgentDebugConversation,
AgentWorkspaceBinding,
)
from models.agent_config_entities import AgentSoulConfig
from models.enums import (
ConversationFromSource,
EndUserType,
@@ -31,40 +19,20 @@ from models.enums import (
)
from models.model import (
App,
AppAnnotationSetting,
AppMode,
AppModelConfig,
Conversation,
EndUser,
Message,
MessageFeedback,
)
from models.workflow import Workflow, WorkflowType
from repositories.sqlalchemy_execution_extra_content_repository import SQLAlchemyExecutionExtraContentRepository
from services.errors.message import (
FirstMessageNotExistsError,
LastMessageNotExistsError,
MessageNotExistsError,
SuggestedQuestionsAfterAnswerDisabledError,
)
from services.message_service import MessageService, attach_message_extra_contents
SQLITE_MODELS = (
Conversation,
Message,
MessageFeedback,
AppModelConfig,
AppAnnotationSetting,
AgentConfigDraft,
AgentConfigSnapshot,
AgentDebugConversation,
AgentWorkspaceBinding,
)
pytestmark = [
pytest.mark.usefixtures("sqlite_session"),
pytest.mark.parametrize("sqlite_session", [SQLITE_MODELS], indirect=True),
]
@dataclass(eq=False, repr=False)
class _DatabaseBinding:
@@ -110,19 +78,6 @@ class MessageServiceTestDataFactory:
account.id = user_id
return account
@staticmethod
def create_workflow(*, features: dict[str, object] | None = None) -> Workflow:
return Workflow(
id="workflow-123",
tenant_id="tenant-123",
app_id="app-123",
type=WorkflowType.CHAT,
version="1",
graph="{}",
_features=json.dumps(features or {}),
created_by="account-123",
)
@staticmethod
def create_conversation(
conversation_id: str = "conv-001",
@@ -700,458 +655,3 @@ class TestMessageServiceFeedback:
)
assert [record["id"] for record in result] == [newest.id]
class TestMessageServiceSuggestedQuestions:
@staticmethod
def _agent_soul(
*,
enabled: bool = True,
prompt: str | None = None,
model: dict[str, object] | None = None,
) -> AgentSoulConfig:
suggested_questions: dict[str, object] = {"enabled": enabled}
if prompt is not None:
suggested_questions["prompt"] = prompt
if model is not None:
suggested_questions["model"] = model
return AgentSoulConfig.model_validate(
{"app_features": {"suggested_questions_after_answer": suggested_questions}}
)
@staticmethod
def _chat_boundaries(
monkeypatch: pytest.MonkeyPatch,
conversation: Conversation,
) -> tuple[MagicMock, MagicMock, MagicMock]:
message = MessageServiceTestDataFactory.create_message(message_id="msg-123", conversation_id=conversation.id)
monkeypatch.setattr(service_module.MessageService, "get_message", MagicMock(return_value=message))
monkeypatch.setattr(
service_module.ConversationService, "get_conversation", MagicMock(return_value=conversation)
)
model_manager = MagicMock()
monkeypatch.setattr(service_module.ModelManager, "for_tenant", MagicMock(return_value=model_manager))
memory = MagicMock()
memory.return_value.get_history_prompt_text.return_value = "histories"
monkeypatch.setattr(service_module, "TokenBufferMemory", memory)
llm_generator = MagicMock()
llm_generator.generate_suggested_questions_after_answer.return_value = ["Q1?"]
monkeypatch.setattr(service_module, "LLMGenerator", llm_generator)
monkeypatch.setattr(service_module, "TraceQueueManager", MagicMock())
return model_manager, memory, llm_generator
def test_user_none(self, factory: MessageServiceTestDataFactory, sqlite_session: Session) -> None:
with pytest.raises(ValueError, match="user cannot be None"):
MessageService.get_suggested_questions_after_answer(
app_model=factory.create_app(),
user=None,
message_id="msg-123",
invoke_from=InvokeFrom.WEB_APP,
session=sqlite_session,
)
def test_advanced_chat_success(
self,
monkeypatch: pytest.MonkeyPatch,
factory: MessageServiceTestDataFactory,
sqlite_session: Session,
) -> None:
conversation = factory.create_conversation()
_, _, llm_generator = self._chat_boundaries(monkeypatch, conversation)
workflow = factory.create_workflow(features={"suggested_questions_after_answer": {"enabled": True}})
workflow_service = MagicMock()
workflow_service.return_value.get_published_workflow.return_value = workflow
monkeypatch.setattr(service_module, "WorkflowService", workflow_service)
app_config_manager = MagicMock()
app_config_manager.get_app_config.return_value.additional_features.suggested_questions_after_answer = True
monkeypatch.setattr(service_module, "AdvancedChatAppConfigManager", app_config_manager)
result = MessageService.get_suggested_questions_after_answer(
app_model=factory.create_app(mode=AppMode.ADVANCED_CHAT),
user=factory.create_end_user(),
message_id="msg-123",
invoke_from=InvokeFrom.WEB_APP,
session=sqlite_session,
)
assert result == ["Q1?"]
llm_generator.generate_suggested_questions_after_answer.assert_called_once()
@pytest.mark.parametrize("draft_type", [AgentConfigDraftType.DRAFT, AgentConfigDraftType.DEBUG_BUILD])
def test_agent_debug_uses_matching_draft(
self,
draft_type: AgentConfigDraftType,
monkeypatch: pytest.MonkeyPatch,
factory: MessageServiceTestDataFactory,
sqlite_session: Session,
) -> None:
conversation = factory.create_conversation()
conversation.mode = AppMode.AGENT
_, _, llm_generator = self._chat_boundaries(monkeypatch, conversation)
account = factory.create_account()
draft = AgentConfigDraft(
tenant_id="tenant-123",
agent_id="agent-123",
draft_type=draft_type,
account_id=account.id if draft_type == AgentConfigDraftType.DEBUG_BUILD else None,
draft_owner_key=account.id if draft_type == AgentConfigDraftType.DEBUG_BUILD else "",
base_snapshot_id=None,
home_snapshot_id=None,
agent_workspace_binding_id=None,
config_snapshot=self._agent_soul(
prompt=f"{draft_type.value} prompt",
model={
"provider": "openai",
"name": "gpt-4o-mini",
"mode": "chat",
"completion_params": {"temperature": 0.1},
},
),
created_by=account.id,
updated_by=account.id,
)
draft.id = "draft-123"
mapping = AgentDebugConversation(
tenant_id="tenant-123",
agent_id="agent-123",
app_id="app-123",
account_id=account.id,
draft_type=draft_type,
conversation_id=conversation.id,
)
mapping.id = "mapping-123"
app_model_config = AppModelConfig(
app_id="app-123",
suggested_questions_after_answer=json.dumps({"enabled": False}),
)
app_model_config.id = "config-123"
_persist(sqlite_session, draft, mapping, app_model_config)
roster_service = MagicMock()
monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
app = factory.create_app(mode=AppMode.AGENT)
app.app_model_config_id = app_model_config.id
result = MessageService.get_suggested_questions_after_answer(
app_model=app,
user=account,
message_id="msg-123",
invoke_from=InvokeFrom.DEBUGGER,
session=sqlite_session,
)
assert result == ["Q1?"]
roster_service.assert_not_called()
llm_generator.generate_suggested_questions_after_answer.assert_called_once_with(
tenant_id="tenant-123",
histories="histories",
instruction_prompt=f"{draft_type.value} prompt",
model_config={
"provider": "openai",
"name": "gpt-4o-mini",
"mode": "chat",
"completion_params": {"temperature": 0.1},
},
)
def test_agent_published_conversation_uses_bound_snapshot(
self,
monkeypatch: pytest.MonkeyPatch,
factory: MessageServiceTestDataFactory,
sqlite_session: Session,
) -> None:
conversation = factory.create_conversation()
conversation.mode = AppMode.AGENT
conversation.agent_workspace_binding_id = "binding-123"
_, _, llm_generator = self._chat_boundaries(monkeypatch, conversation)
snapshot = AgentConfigSnapshot(
tenant_id="tenant-123",
agent_id="agent-123",
version=1,
config_snapshot=self._agent_soul(prompt="bound snapshot prompt"),
home_snapshot_id=None,
summary=None,
version_note=None,
created_by="account-123",
)
snapshot.id = "snapshot-123"
binding = AgentWorkspaceBinding(
tenant_id="tenant-123",
app_id="app-123",
workspace_id="workspace-123",
agent_id="agent-123",
base_home_snapshot_id=None,
agent_config_version_id=snapshot.id,
agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT,
backend_binding_ref="backend-binding-123",
session_snapshot=None,
retired_at=None,
pending_form_id=None,
pending_tool_call_id=None,
)
binding.id = "binding-123"
_persist(sqlite_session, snapshot, binding)
roster_service = MagicMock()
roster_service.return_value.get_published_agent_soul_for_app.return_value = self._agent_soul(
prompt="current published prompt"
)
monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
result = MessageService.get_suggested_questions_after_answer(
app_model=factory.create_app(mode=AppMode.AGENT),
user=factory.create_end_user(),
message_id="msg-123",
invoke_from=InvokeFrom.SERVICE_API,
session=sqlite_session,
)
assert result == ["Q1?"]
roster_service.assert_not_called()
llm_generator.generate_suggested_questions_after_answer.assert_called_once_with(
tenant_id="tenant-123",
histories="histories",
instruction_prompt="bound snapshot prompt",
model_config=None,
)
def test_agent_without_binding_uses_published_soul(
self,
monkeypatch: pytest.MonkeyPatch,
factory: MessageServiceTestDataFactory,
sqlite_session: Session,
) -> None:
conversation = factory.create_conversation()
conversation.mode = AppMode.AGENT
_, _, llm_generator = self._chat_boundaries(monkeypatch, conversation)
roster_service = MagicMock()
roster_service.return_value.get_published_agent_soul_for_app.return_value = self._agent_soul(
prompt="published prompt"
)
monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
result = MessageService.get_suggested_questions_after_answer(
app_model=factory.create_app(mode=AppMode.AGENT),
user=factory.create_end_user(),
message_id="msg-123",
invoke_from=InvokeFrom.SERVICE_API,
session=sqlite_session,
)
assert result == ["Q1?"]
roster_service.return_value.get_published_agent_soul_for_app.assert_called_once_with(
tenant_id="tenant-123",
app_id="app-123",
)
llm_generator.generate_suggested_questions_after_answer.assert_called_once_with(
tenant_id="tenant-123",
histories="histories",
instruction_prompt="published prompt",
model_config=None,
)
def test_agent_unavailable_default_model_returns_empty_questions(
self,
monkeypatch: pytest.MonkeyPatch,
factory: MessageServiceTestDataFactory,
sqlite_session: Session,
) -> None:
conversation = factory.create_conversation()
conversation.mode = AppMode.AGENT
model_manager, memory, llm_generator = self._chat_boundaries(monkeypatch, conversation)
model_manager.get_default_model_instance.side_effect = ValueError("unsupported default model")
roster_service = MagicMock()
roster_service.return_value.get_published_agent_soul_for_app.return_value = self._agent_soul()
monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
result = MessageService.get_suggested_questions_after_answer(
app_model=factory.create_app(mode=AppMode.AGENT),
user=factory.create_end_user(),
message_id="msg-123",
invoke_from=InvokeFrom.SERVICE_API,
session=sqlite_session,
)
assert result == []
memory.assert_not_called()
llm_generator.generate_suggested_questions_after_answer.assert_not_called()
def test_historical_agent_without_soul_uses_current_app_model_config(
self,
monkeypatch: pytest.MonkeyPatch,
factory: MessageServiceTestDataFactory,
sqlite_session: Session,
) -> None:
conversation = factory.create_conversation()
conversation.mode = AppMode.AGENT
_, _, llm_generator = self._chat_boundaries(monkeypatch, conversation)
app_model_config = AppModelConfig(
app_id="app-123",
suggested_questions_after_answer=json.dumps({"enabled": True, "prompt": "legacy prompt"}),
)
app_model_config.id = "config-123"
_persist(sqlite_session, app_model_config)
app = factory.create_app(mode=AppMode.AGENT)
app.app_model_config_id = app_model_config.id
roster_service = MagicMock()
roster_service.return_value.get_published_agent_soul_for_app.return_value = None
monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
result = MessageService.get_suggested_questions_after_answer(
app_model=app,
user=factory.create_end_user(),
message_id="msg-123",
invoke_from=InvokeFrom.SERVICE_API,
session=sqlite_session,
)
assert result == ["Q1?"]
llm_generator.generate_suggested_questions_after_answer.assert_called_once_with(
tenant_id="tenant-123",
histories="histories",
instruction_prompt="legacy prompt",
model_config=None,
)
def test_agent_disabled_raises_disabled_error(
self,
monkeypatch: pytest.MonkeyPatch,
factory: MessageServiceTestDataFactory,
sqlite_session: Session,
) -> None:
conversation = factory.create_conversation()
conversation.mode = AppMode.AGENT
self._chat_boundaries(monkeypatch, conversation)
roster_service = MagicMock()
roster_service.return_value.get_published_agent_soul_for_app.return_value = self._agent_soul(enabled=False)
monkeypatch.setattr("services.agent.roster_service.AgentRosterService", roster_service)
with pytest.raises(SuggestedQuestionsAfterAnswerDisabledError):
MessageService.get_suggested_questions_after_answer(
app_model=factory.create_app(mode=AppMode.AGENT),
user=factory.create_end_user(),
message_id="msg-123",
invoke_from=InvokeFrom.SERVICE_API,
session=sqlite_session,
)
@pytest.mark.parametrize(
("config", "expected_prompt", "expected_model"),
[
({"enabled": True}, None, None),
(
{
"enabled": True,
"prompt": "custom prompt",
"model": {
"provider": "openai",
"name": "gpt-4o-mini",
"completion_params": {"max_tokens": 2048, "temperature": 0.1},
},
},
"custom prompt",
{
"provider": "openai",
"name": "gpt-4o-mini",
"completion_params": {"max_tokens": 2048, "temperature": 0.1},
},
),
(
{"enabled": True, "model": {"provider": "openai", "name": "invalid-model"}},
None,
{"provider": "openai", "name": "invalid-model"},
),
],
)
def test_chat_app_uses_persisted_model_config(
self,
config: dict[str, object],
expected_prompt: str | None,
expected_model: dict[str, object] | None,
monkeypatch: pytest.MonkeyPatch,
factory: MessageServiceTestDataFactory,
sqlite_session: Session,
) -> None:
app_model_config = AppModelConfig(
app_id="app-123",
suggested_questions_after_answer=json.dumps(config),
)
app_model_config.id = "config-1"
conversation = factory.create_conversation(app_model_config_id=app_model_config.id)
_persist(sqlite_session, app_model_config)
model_manager, memory, llm_generator = self._chat_boundaries(monkeypatch, conversation)
result = MessageService.get_suggested_questions_after_answer(
app_model=factory.create_app(mode=AppMode.CHAT),
user=factory.create_end_user(),
message_id="msg-123",
invoke_from=InvokeFrom.WEB_APP,
session=sqlite_session,
)
assert result == ["Q1?"]
model_manager.get_default_model_instance.assert_called_once_with(
tenant_id="tenant-123", model_type=ModelType.LLM
)
memory.assert_called_once_with(
conversation=conversation,
model_instance=model_manager.get_default_model_instance.return_value,
)
llm_generator.generate_suggested_questions_after_answer.assert_called_once_with(
tenant_id="tenant-123",
histories="histories",
instruction_prompt=expected_prompt,
model_config=expected_model,
)
def test_chat_app_uses_compatible_override_model_config(
self,
monkeypatch: pytest.MonkeyPatch,
factory: MessageServiceTestDataFactory,
sqlite_session: Session,
) -> None:
override = {
"model": {"provider": "openai", "name": "gpt-4o-mini", "mode": "chat"},
"suggested_questions_after_answer": {"enabled": True, "prompt": "legacy prompt"},
}
conversation = factory.create_conversation(override_model_configs=json.dumps(override))
_, _, llm_generator = self._chat_boundaries(monkeypatch, conversation)
result = MessageService.get_suggested_questions_after_answer(
app_model=factory.create_app(mode=AppMode.CHAT),
user=factory.create_end_user(),
message_id="msg-123",
invoke_from=InvokeFrom.WEB_APP,
session=sqlite_session,
)
assert result == ["Q1?"]
llm_generator.generate_suggested_questions_after_answer.assert_called_once_with(
tenant_id="tenant-123",
histories="histories",
instruction_prompt="legacy prompt",
model_config=None,
)
def test_disabled_error(
self,
monkeypatch: pytest.MonkeyPatch,
factory: MessageServiceTestDataFactory,
sqlite_session: Session,
) -> None:
conversation = factory.create_conversation()
self._chat_boundaries(monkeypatch, conversation)
workflow = factory.create_workflow()
workflow_service = MagicMock()
workflow_service.return_value.get_published_workflow.return_value = workflow
monkeypatch.setattr(service_module, "WorkflowService", workflow_service)
app_config_manager = MagicMock()
app_config_manager.get_app_config.return_value.additional_features.suggested_questions_after_answer = False
monkeypatch.setattr(service_module, "AdvancedChatAppConfigManager", app_config_manager)
with pytest.raises(SuggestedQuestionsAfterAnswerDisabledError):
MessageService.get_suggested_questions_after_answer(
app_model=factory.create_app(mode=AppMode.ADVANCED_CHAT),
user=factory.create_end_user(),
message_id="msg-123",
invoke_from=InvokeFrom.WEB_APP,
session=sqlite_session,
)
@@ -1,637 +0,0 @@
"""Shared runtime ownership, configuration selection, and session lifecycle."""
import json
from collections.abc import Iterator, Mapping, Sequence
from dataclasses import dataclass, field
from decimal import Decimal
from typing import Literal, cast
from uuid import uuid4
import httpx
import pytest
from flask import Flask, g
from sqlalchemy import Connection, Engine, event, update
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
import services.message_service as message_module
from core.credit_usage import CreditUsageAppType, CreditUsageCreatedBy
from core.file import remote_fetcher
from core.model_context import get_credit_usage_metadata, use_credit_usage_metadata
from core.model_manager import ModelInstance
from core.ops.ops_trace_manager import TraceTask
from extensions.ext_database import db
from graphon.file import FileTransferMethod, FileType
from graphon.model_runtime.entities import AssistantPromptMessage, PromptMessage
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage
from graphon.model_runtime.entities.model_entities import AIModelEntity, ModelType
from models import Account, App, AppMode, Conversation, Message
from models.enums import ConversationFromSource, CreatorUserRole, EndUserType
from models.model import AppModelConfig, EndUser, MessageFile
from models.workflow import Workflow, WorkflowType
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.errors.conversation import ConversationNotExistsError
from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError
from services.message_suggested_questions_adapters import MessageSuggestedQuestionsRuntime
from services.message_suggested_questions_service import (
SuggestedQuestionsAccount,
SuggestedQuestionsActor,
SuggestedQuestionsActorNotFoundError,
SuggestedQuestionsEndUser,
)
type _InvokeFrom = Literal["debugger", "explore", "web-app", "service-api"]
@dataclass
class _Provider:
read_sessions: list[Session]
tenant_id: str
app_id: str
prompts: list[str] = field(default_factory=list)
model_requests: list[tuple[str, str]] = field(default_factory=list)
invocation_parameters: list[Mapping[str, object]] = field(default_factory=list)
metadata: list[Mapping[str, object] | None] = field(default_factory=list)
traces: list[TraceTask] = field(default_factory=list)
model_sessions: list[Session] = field(default_factory=list)
io_sessions: list[tuple[str, bool]] = field(default_factory=list)
failure: Exception | None = None
token_failure: Exception | None = None
history_model_failure: bool = False
generation_model_failure: bool = False
def record_io(self, stage: str) -> None:
self.io_sessions.append(
(
stage,
all(
not session.in_transaction() and not session.identity_map
for session in self.read_sessions + self.model_sessions
),
)
)
def _resolve_model(self, *, tenant_id: str, model_type: ModelType) -> ModelInstance:
assert tenant_id == self.tenant_id
assert model_type == ModelType.LLM
assert self.read_sessions
assert all(not session.in_transaction() and not session.identity_map for session in self.read_sessions)
# Simulate ModelManager's real scoped database lookup, so both resolution
# stages must dispose their sessions before token/schema/provider I/O.
session = db.session()
assert session.get(App, self.app_id) is not None
self.model_sessions.append(session)
if self.history_model_failure or (self.generation_model_failure and len(self.model_sessions) > 1):
raise RuntimeError("No model available")
return cast(ModelInstance, self)
def get_default_model_instance(self, *, tenant_id: str, model_type: ModelType) -> ModelInstance:
self.model_requests.append(("default", "default"))
return self._resolve_model(tenant_id=tenant_id, model_type=model_type)
def get_model_instance(self, *, tenant_id: str, model_type: ModelType, provider: str, model: str) -> ModelInstance:
self.model_requests.append((provider, model))
return self._resolve_model(tenant_id=tenant_id, model_type=model_type)
def get_llm_num_tokens(self, prompt_messages: Sequence[PromptMessage]) -> int:
self.record_io("tokens")
if self.token_failure is not None:
raise self.token_failure
return len(prompt_messages)
def get_model_schema(self) -> AIModelEntity:
self.record_io("schema")
return AIModelEntity.model_construct(parameter_rules=[])
def invoke_llm(
self,
*,
prompt_messages: list[PromptMessage],
model_parameters: Mapping[str, object],
stop: list[str],
stream: bool,
) -> LLMResult:
self.record_io("invoke")
assert stream is False
assert stop == []
self.invocation_parameters.append(model_parameters)
self.prompts.append(prompt_messages[0].get_text_content())
self.metadata.append(get_credit_usage_metadata())
if self.failure is not None:
raise self.failure
return LLMResult(
model="question-model",
message=AssistantPromptMessage(content='["What next?"]'),
usage=LLMUsage.empty_usage(),
)
def add_trace_task(self, task: TraceTask) -> None:
self.traces.append(task)
@dataclass(frozen=True)
class _Harness:
flask_app: Flask
target: App
account: Account
end_user: EndUser
conversation: Conversation
message: Message
factory: sessionmaker[Session]
runtime: MessageSuggestedQuestionsRuntime
provider: _Provider
legacy_sessions: list[Session]
def actor(self, invoke_from: _InvokeFrom) -> SuggestedQuestionsActor:
if invoke_from in {"explore", "debugger"}:
with self.factory.begin() as session:
for model, record_id in ((Message, self.message.id), (Conversation, self.conversation.id)):
session.execute(
update(model)
.where(model.id == record_id)
.values(
from_source=ConversationFromSource.CONSOLE,
from_account_id=self.account.id,
from_end_user_id=None,
)
)
return SuggestedQuestionsAccount(account_id=self.account.id, invoke_from=invoke_from)
return SuggestedQuestionsEndUser(end_user_id=self.end_user.id, invoke_from=invoke_from)
def get(self, actor: SuggestedQuestionsActor) -> list[str]:
return self.runtime.get_suggested_questions(
app_id=self.target.id,
app_owner_tenant_id=self.target.tenant_id,
expected_app_mode=self.target.mode,
actor=actor,
message_id=self.message.id,
)
def assert_closed(self) -> None:
assert all(
not session.in_transaction() and not session.identity_map
for session in self.provider.read_sessions + self.legacy_sessions
)
assert all(closed for _stage, closed in self.provider.io_sessions), self.provider.io_sessions
@pytest.fixture
def harness(
monkeypatch: pytest.MonkeyPatch,
sqlite_engine: Engine,
sqlite_session_factory: sessionmaker[Session],
) -> Iterator[_Harness]:
target = App(
id=str(uuid4()), tenant_id=str(uuid4()), name="Shared", mode=AppMode.CHAT, enable_site=True, enable_api=True
)
account = Account(name="Debugger", email="debugger@example.com")
end_user = EndUser(
tenant_id=target.tenant_id, app_id=target.id, type=EndUserType.SERVICE_API, session_id=str(uuid4())
)
config = AppModelConfig(
app_id=target.id, suggested_questions_after_answer='{"enabled":true,"prompt":"Published questions"}'
)
with sqlite_session_factory.begin() as session:
session.add_all([target, account, end_user, config])
session.flush()
target.app_model_config_id = config.id
conversation = Conversation(
app_id=target.id,
app_model_config_id=config.id,
mode=AppMode.CHAT,
name="End-user conversation",
_inputs={},
from_source=ConversationFromSource.API,
from_end_user_id=end_user.id,
)
session.add(conversation)
session.flush()
message = Message(
app_id=target.id,
conversation_id=conversation.id,
_inputs={},
query="How does this work?",
message={},
message_unit_price=Decimal(0),
answer="Like this.",
answer_unit_price=Decimal(0),
currency="USD",
from_source=ConversationFromSource.API,
from_end_user_id=end_user.id,
)
session.add(message)
read_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
read_sessions: list[Session] = []
@event.listens_for(read_factory, "after_begin")
def track_read(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
read_sessions.append(session)
provider = _Provider(read_sessions, target.tenant_id, target.id)
def model_manager(*, tenant_id: str) -> _Provider:
assert tenant_id == target.tenant_id
return provider
def trace_manager(*, app_id: str) -> _Provider:
assert app_id == target.id
return provider
monkeypatch.setattr(message_module.ModelManager, "for_tenant", model_manager)
monkeypatch.setattr(message_module, "TraceQueueManager", trace_manager)
flask_app = Flask(__name__)
flask_app.config.update(TESTING=True, SQLALCHEMY_DATABASE_URI=str(sqlite_engine.url))
db.init_app(flask_app)
legacy_sessions: list[Session] = []
def track_legacy(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
legacy_sessions.append(session)
event.listen(db.session.session_factory, "after_begin", track_legacy)
yield _Harness(
flask_app,
target,
account,
end_user,
conversation,
message,
sqlite_session_factory,
MessageSuggestedQuestionsRuntime(session_factory=read_factory),
provider,
legacy_sessions,
)
event.remove(db.session.session_factory, "after_begin", track_legacy)
with flask_app.app_context():
db.session.remove()
db.engine.dispose()
@pytest.mark.parametrize("change", ["app_id", "tenant", "mode", "account"])
def test_runtime_rejects_stale_reference_before_legacy_queries(
harness: _Harness, change: Literal["app_id", "tenant", "mode", "account"]
) -> None:
account_id = str(uuid4()) if change == "account" else harness.account.id
app_id = str(uuid4()) if change == "app_id" else harness.target.id
error_type = SuggestedQuestionsActorNotFoundError if change == "account" else AppDefinitionUnavailableError
with pytest.raises(error_type, match=account_id if change == "account" else app_id):
harness.runtime.get_suggested_questions(
app_id=app_id,
app_owner_tenant_id=str(uuid4()) if change == "tenant" else harness.target.tenant_id,
expected_app_mode=AppMode.ADVANCED_CHAT if change == "mode" else harness.target.mode,
actor=SuggestedQuestionsAccount(account_id=account_id, invoke_from="explore"),
message_id=harness.message.id,
)
assert len(harness.provider.read_sessions) == 1
assert not harness.legacy_sessions
harness.assert_closed()
@pytest.mark.parametrize("change", ["missing", "tenant", "app"])
def test_end_user_reload_requires_the_admitted_app_owner(
harness: _Harness, change: Literal["missing", "tenant", "app"]
) -> None:
end_user_id = str(uuid4()) if change == "missing" else harness.end_user.id
if change != "missing":
values = {"tenant_id" if change == "tenant" else "app_id": str(uuid4())}
with harness.factory.begin() as session:
session.execute(update(EndUser).where(EndUser.id == end_user_id).values(values))
with pytest.raises(SuggestedQuestionsActorNotFoundError, match=end_user_id):
harness.get(SuggestedQuestionsEndUser(end_user_id=end_user_id, invoke_from="service-api"))
assert not harness.legacy_sessions
harness.assert_closed()
@pytest.mark.parametrize("invoke_from", ["debugger", "explore", "web-app", "service-api"])
@pytest.mark.parametrize("provider_failure", [False, True])
def test_runtime_preserves_history_configuration_credit_context_and_caller_session(
harness: _Harness, invoke_from: _InvokeFrom, provider_failure: bool
) -> None:
if provider_failure:
harness.provider.failure = RuntimeError("Provider unavailable")
actor = harness.actor(invoke_from)
model_config = {"provider": "vendor", "name": "question-model"}
with harness.factory.begin() as session:
session.execute(
update(AppModelConfig)
.where(AppModelConfig.id == harness.target.app_model_config_id)
.values(
suggested_questions_after_answer=json.dumps(
{"enabled": True, "prompt": "Published questions", "model": model_config}
)
)
)
with harness.flask_app.app_context():
caller_session = db.session()
caller_app = caller_session.get(App, harness.target.id)
assert caller_app is not None
caller_app.name = "Pending caller edit"
harness.legacy_sessions.clear()
original_metadata = get_credit_usage_metadata()
with use_credit_usage_metadata({"request_id": "suggestions-request"}):
inherited_metadata = get_credit_usage_metadata()
assert harness.get(actor) == ([] if provider_failure else ["What next?"])
assert get_credit_usage_metadata() == inherited_metadata
assert harness.provider.metadata == [
{
**(inherited_metadata or {}),
"app_type": CreditUsageAppType.CHATBOT,
"created_by": CreditUsageCreatedBy.SUGGESTED_QUESTIONS,
}
]
assert get_credit_usage_metadata() == original_metadata
assert db.session() is caller_session
assert caller_session.in_transaction()
assert caller_app in caller_session.dirty
assert caller_app.name == "Pending caller edit"
assert len(harness.provider.model_sessions) == 2
assert harness.provider.model_sessions[0] is not harness.provider.model_sessions[1]
assert all(session is not caller_session for session in harness.legacy_sessions)
harness.assert_closed()
assert len(harness.provider.prompts) == 1
assert harness.provider.prompts[0].startswith(
"Human: How does this work?\nAssistant: Like this.\nPublished questions\n"
)
assert harness.provider.model_requests == [("default", "default"), ("vendor", "question-model")]
assert [stage for stage, _closed in harness.provider.io_sessions] == ["tokens", "invoke"]
assert harness.provider.invocation_parameters == [{}]
assert len(harness.provider.traces) == 1
@pytest.mark.parametrize("invoke_from", ["explore", "service-api"])
@pytest.mark.parametrize("entity", ["message", "conversation"])
@pytest.mark.parametrize("change", ["id", "app_id", "from_account_id", "from_end_user_id", "from_source"])
def test_message_and_conversation_ownership_are_enforced(
harness: _Harness,
invoke_from: Literal["explore", "service-api"],
entity: Literal["message", "conversation"],
change: Literal["id", "app_id", "from_account_id", "from_end_user_id", "from_source"],
) -> None:
actor = harness.actor(invoke_from)
model = Message if entity == "message" else Conversation
record_id = harness.message.id if entity == "message" else harness.conversation.id
if change == "from_source":
value = ConversationFromSource.API if invoke_from == "explore" else ConversationFromSource.CONSOLE
else:
value = str(uuid4())
with harness.factory.begin() as session:
session.execute(update(model).where(model.id == record_id).values({change: value}))
error_type = MessageNotExistsError if entity == "message" else ConversationNotExistsError
with harness.flask_app.app_context(), pytest.raises(error_type):
harness.get(actor)
assert not harness.provider.prompts
harness.assert_closed()
@pytest.mark.parametrize("invoke_from", ["explore", "web-app"])
def test_actor_cannot_read_a_deleted_conversation(
harness: _Harness, invoke_from: Literal["explore", "web-app"]
) -> None:
actor = harness.actor(invoke_from)
with harness.factory.begin() as session:
session.execute(update(Conversation).where(Conversation.id == harness.conversation.id).values(is_deleted=True))
with harness.flask_app.app_context(), pytest.raises(ConversationNotExistsError):
harness.get(actor)
assert not harness.provider.prompts
harness.assert_closed()
@pytest.mark.parametrize("case", ["disabled", "missing_workflow", "history_provider_failure"])
def test_account_preserves_disabled_feature_and_missing_workflow_or_model_behavior(
harness: _Harness, case: Literal["disabled", "missing_workflow", "history_provider_failure"]
) -> None:
actor = harness.actor("explore")
with harness.factory.begin() as session:
if case == "disabled":
session.execute(
update(AppModelConfig)
.where(AppModelConfig.id == harness.target.app_model_config_id)
.values(suggested_questions_after_answer='{"enabled":false}')
)
elif case == "missing_workflow":
session.execute(update(App).where(App.id == harness.target.id).values(mode=AppMode.ADVANCED_CHAT))
# Admit the new mode so this covers missing workflow, not a stale reference.
harness.target.mode = AppMode.ADVANCED_CHAT
else:
harness.provider.history_model_failure = True
with harness.flask_app.app_context():
if case == "disabled":
with pytest.raises(SuggestedQuestionsAfterAnswerDisabledError):
harness.get(actor)
else:
assert harness.get(actor) == []
assert len(harness.legacy_sessions) == (1 if case == "history_provider_failure" else 0)
assert not harness.provider.prompts
assert not harness.provider.traces
harness.assert_closed()
@pytest.mark.parametrize("invoke_from", ["debugger", "explore"])
def test_advanced_chat_debugger_uses_draft_while_explore_uses_published_workflow(
harness: _Harness, invoke_from: Literal["debugger", "explore"]
) -> None:
actor = harness.actor(invoke_from)
with harness.factory.begin() as session:
for version, prompt in ((Workflow.VERSION_DRAFT, "Draft questions"), ("published", "Published questions")):
workflow = Workflow(
tenant_id=harness.target.tenant_id,
app_id=harness.target.id,
type=WorkflowType.CHAT,
version=version,
graph='{"nodes":[],"edges":[]}',
features=json.dumps({"suggested_questions_after_answer": {"enabled": True, "prompt": prompt}}),
created_by=harness.account.id,
)
session.add(workflow)
session.flush()
if version != Workflow.VERSION_DRAFT:
session.execute(update(App).where(App.id == harness.target.id).values({App.workflow_id: workflow.id}))
session.execute(update(App).where(App.id == harness.target.id).values(mode=AppMode.ADVANCED_CHAT))
session.execute(
update(Conversation).where(Conversation.id == harness.conversation.id).values(mode=AppMode.ADVANCED_CHAT)
)
with harness.flask_app.app_context():
assert harness.runtime.get_suggested_questions(
app_id=harness.target.id,
app_owner_tenant_id=harness.target.tenant_id,
expected_app_mode=AppMode.ADVANCED_CHAT,
actor=actor,
message_id=harness.message.id,
) == ["What next?"]
expected_prompt = "Draft questions" if invoke_from == "debugger" else "Published questions"
assert len(harness.provider.prompts) == 1
assert expected_prompt in harness.provider.prompts[0]
assert harness.provider.model_requests == [("default", "default"), ("default", "default")]
assert [stage for stage, _closed in harness.provider.io_sessions] == ["tokens", "schema", "invoke"]
harness.assert_closed()
@pytest.mark.parametrize("failure_stage", ["tokens", "generation_model"])
def test_runtime_releases_sessions_when_history_or_second_model_stage_fails(
harness: _Harness, failure_stage: Literal["tokens", "generation_model"]
) -> None:
actor = harness.actor("explore")
if failure_stage == "tokens":
harness.provider.token_failure = RuntimeError("Token counter unavailable")
else:
harness.provider.generation_model_failure = True
with harness.flask_app.app_context():
if failure_stage == "tokens":
with pytest.raises(RuntimeError, match="Token counter unavailable"):
harness.get(actor)
else:
assert harness.get(actor) == []
assert len(harness.provider.model_sessions) == (1 if failure_stage == "tokens" else 2)
assert not harness.provider.prompts
assert len(harness.provider.traces) == (0 if failure_stage == "tokens" else 1)
harness.assert_closed()
@pytest.mark.parametrize("remote_failure", [False, True])
def test_remote_attachment_is_loaded_after_history_and_model_sessions_close(
harness: _Harness, monkeypatch: pytest.MonkeyPatch, remote_failure: bool
) -> None:
actor = harness.actor("explore")
with harness.factory.begin() as session:
session.execute(
update(AppModelConfig)
.where(AppModelConfig.id == harness.target.app_model_config_id)
.values(
file_upload=json.dumps(
{"enabled": True, "allowed_file_types": ["image"], "allowed_file_upload_methods": ["remote_url"]}
)
)
)
session.add(
MessageFile(
message_id=harness.message.id,
type=FileType.IMAGE,
transfer_method=FileTransferMethod.REMOTE_URL,
url="https://example.com/history.png",
created_by_role=CreatorUserRole.ACCOUNT,
created_by=harness.account.id,
)
)
requests: list[str] = []
def fetch(method: str, url: str, **_kwargs: object) -> httpx.Response:
harness.provider.record_io("attachment")
requests.append(method)
assert url == "https://example.com/history.png"
if remote_failure:
raise RuntimeError("Attachment unavailable")
return httpx.Response(
200,
headers={"content-type": "image/png", "content-length": "3"},
content=b"png",
request=httpx.Request(method, url),
)
monkeypatch.setattr(remote_fetcher, "make_request", fetch)
with harness.flask_app.app_context():
if remote_failure:
with pytest.raises(RuntimeError, match="Attachment unavailable"):
harness.get(actor)
else:
assert harness.get(actor) == ["What next?"]
assert requests
assert requests[0] == "HEAD"
if remote_failure:
assert not harness.provider.prompts
assert not harness.provider.traces
else:
assert harness.provider.prompts[0].startswith("Human: [image]\nHow does this work?\nAssistant: Like this.")
harness.assert_closed()
@pytest.mark.parametrize("failure_stage", [None, "provider_sql", "trace"])
def test_runtime_keeps_its_context_state_while_releasing_sessions_and_preserving_the_caller(
harness: _Harness, monkeypatch: pytest.MonkeyPatch, failure_stage: Literal["provider_sql", "trace"] | None
) -> None:
actor = harness.actor("explore")
runtime_steps: list[str] = []
invocation_sessions: list[Session] = []
trace_sessions: list[Session] = []
resolve_model = harness.provider._resolve_model
count_tokens = harness.provider.get_llm_num_tokens
invoke_llm = harness.provider.invoke_llm
def resolve(*, tenant_id: str, model_type: ModelType) -> ModelInstance:
assert g.get("caller_marker") is None
if not runtime_steps:
g.suggested_question_steps = runtime_steps
assert g.get("suggested_question_steps") is runtime_steps
runtime_steps.append("model")
return resolve_model(tenant_id=tenant_id, model_type=model_type)
def tokens(prompt_messages: Sequence[PromptMessage]) -> int:
assert g.get("suggested_question_steps") is runtime_steps
runtime_steps.append("tokens")
return count_tokens(prompt_messages)
def invoke(
*, prompt_messages: list[PromptMessage], model_parameters: Mapping[str, object], stop: list[str], stream: bool
) -> LLMResult:
assert g.get("suggested_question_steps") is runtime_steps
runtime_steps.append("invoke")
if failure_stage == "provider_sql":
# A real failed flush poisons this scoped session. LLMGenerator
# catches the error, so tracing must receive a fresh session.
session = db.session()
invocation_sessions.append(session)
session.add(
App(id=harness.target.id, tenant_id=harness.target.tenant_id, name="Duplicate", mode=AppMode.CHAT)
)
try:
session.flush()
finally:
assert not session.is_active
return invoke_llm(prompt_messages=prompt_messages, model_parameters=model_parameters, stop=stop, stream=stream)
def trace(task: TraceTask) -> None:
assert g.get("suggested_question_steps") is runtime_steps
runtime_steps.append("trace")
session = db.session()
trace_sessions.append(session)
app = session.get(App, harness.target.id)
assert app is not None
assert app.name == "Shared"
if failure_stage == "trace":
raise RuntimeError("Trace backend unavailable")
harness.provider.traces.append(task)
monkeypatch.setattr(harness.provider, "_resolve_model", resolve)
monkeypatch.setattr(harness.provider, "get_llm_num_tokens", tokens)
monkeypatch.setattr(harness.provider, "invoke_llm", invoke)
monkeypatch.setattr(harness.provider, "add_trace_task", trace)
with harness.flask_app.app_context():
caller_marker = object()
g.caller_marker = caller_marker
caller_session = db.session()
caller_app = caller_session.get(App, harness.target.id)
assert caller_app is not None
caller_app.name = "Pending caller edit"
harness.legacy_sessions.clear()
if failure_stage == "trace":
with pytest.raises(RuntimeError, match="Trace backend unavailable"):
harness.get(actor)
else:
assert harness.get(actor) == ([] if failure_stage == "provider_sql" else ["What next?"])
assert g.caller_marker is caller_marker
assert g.get("suggested_question_steps") is None
assert db.session() is caller_session
assert caller_session.in_transaction()
assert caller_app in caller_session.dirty
assert caller_app.name == "Pending caller edit"
assert all(session is not caller_session for session in harness.legacy_sessions)
harness.assert_closed()
assert runtime_steps == ["model", "tokens", "model", "invoke", "trace"]
assert len(trace_sessions) == 1
assert not trace_sessions[0].in_transaction()
if failure_stage == "provider_sql":
assert len(invocation_sessions) == 1
assert trace_sessions[0] is not invocation_sessions[0]
with harness.factory() as session:
persisted_app = session.get(App, harness.target.id)
assert persisted_app is not None
assert persisted_app.name == "Shared"
@@ -0,0 +1,326 @@
"""Real model-resolution failures and deterministic suggested-question transformations.
Successful model invocation requires a configured plugin/model backend. These
unit tests do not replace that backend or claim to cover successful LLM calls.
"""
from collections.abc import Callable, Iterator
from uuid import uuid4
import httpx
import pytest
from flask import Flask, current_app
from pydantic import ValidationError
from sqlalchemy import Connection, Engine, event, select
from sqlalchemy.exc import IntegrityError, PendingRollbackError
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
from yarl import URL
import core.plugin.impl.base as plugin_base
import core.plugin.impl.model_runtime as model_runtime_module
from core.llm_generator.llm_generator import _normalize_completion_params
from core.llm_generator.output_parser.suggested_questions_after_answer import SuggestedQuestionsAfterAnswerOutputParser
from core.llm_generator.prompts import DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT
from core.memory.token_buffer_memory import HistoryPrompt, PreparedHistory
from core.model_context import get_credit_usage_metadata
from core.plugin.entities.plugin_daemon import PluginDaemonInnerError
from core.plugin.impl.base import _get_plugin_daemon_request_timeout
from extensions.ext_database import db
from extensions.ext_redis import RedisClientWrapper
from graphon.model_runtime.entities.message_entities import ImagePromptMessageContent
from graphon.model_runtime.entities.model_entities import ModelType
from models.provider import Provider, TenantDefaultModel
from services.entities.message_entities import MessageEndUser
from services.message_suggested_questions_generator import (
PreparedSuggestedQuestionsModel,
SuggestedQuestionsGenerator,
_default_suggested_questions_model_parameters,
)
from services.message_suggested_questions_service import SuggestedQuestionsContext
from tests.helpers.unit.core.model_fixtures import make_model_instance
from tests.helpers.unit.model_factories import make_app
@pytest.fixture
def invalid_provider_tenant(
_empty_model_provider_daemon: None,
sqlite_engine: Engine,
sqlite_session_factory: sessionmaker[Session],
) -> Iterator[str]:
tenant_id = str(uuid4())
with sqlite_session_factory.begin() as session:
session.add(Provider(tenant_id=tenant_id, provider_name="invalid/provider", is_valid=True))
session.add(
TenantDefaultModel(
tenant_id=tenant_id,
provider_name="invalid/provider",
model_name="invalid-model",
model_type=ModelType.LLM,
)
)
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = str(sqlite_engine.url)
app.config["SQLALCHEMY_ENGINE_OPTIONS"] = {"pool_pre_ping": True}
db.init_app(app)
with app.app_context():
yield tenant_id
db.session.remove()
db.engine.dispose()
def test_prepare_preserves_caller_transaction_when_stored_provider_is_invalid(
invalid_provider_tenant: str, caplog: pytest.LogCaptureFixture
) -> None:
context = SuggestedQuestionsContext(
app_id=str(uuid4()),
tenant_id=invalid_provider_tenant,
app_mode="chat",
message_id=str(uuid4()),
conversation_id=str(uuid4()),
actor=MessageEndUser(end_user_id=str(uuid4())),
invoke_from="service-api",
config={"enabled": True},
)
caller = db.session()
provider = caller.scalars(select(Provider).where(Provider.tenant_id == invalid_provider_tenant)).one()
provider.quota_limit = 27
with SuggestedQuestionsGenerator().prepare(context=context, instruction_prompt=None, model_config=None) as generate:
assert generate is None
assert db.session() is caller
assert caller.in_transaction()
assert provider in caller.dirty
assert provider.quota_limit == 27
assert "invalid/provider" in caplog.text
@pytest.mark.parametrize(
"model_config",
[None, {}, {"provider": "invalid/provider", "name": "model", "completion_params": {"temperature": 0.2}}],
)
def test_model_selection_returns_none_for_invalid_stored_provider(
invalid_provider_tenant: str, model_config: object, caplog: pytest.LogCaptureFixture
) -> None:
metadata = get_credit_usage_metadata()
assert SuggestedQuestionsGenerator._prepare_model(invalid_provider_tenant, model_config=model_config) is None
assert "invalid/provider" in caplog.text
assert get_credit_usage_metadata() == metadata
def test_invocation_failure_returns_empty_questions_and_restores_request_context(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
# Use a real, uninitialized cache client, so no shared Redis mock participates.
# The real runtime then rejects the malformed provider ID before transport I/O.
monkeypatch.setattr(model_runtime_module, "redis_client", RedisClientWrapper())
model = make_model_instance(provider="invalid/provider", model="unavailable")
prepared = PreparedSuggestedQuestionsModel(model_instance=model, completion_params={"temperature": 0.2})
metadata = get_credit_usage_metadata()
timeout = _get_plugin_daemon_request_timeout()
assert SuggestedQuestionsGenerator._invoke(prepared, "Human: hello\nAssistant: world") == []
assert "invalid/provider" in caplog.text
assert get_credit_usage_metadata() == metadata
assert _get_plugin_daemon_request_timeout() == timeout
def test_default_parameters_survive_real_schema_lookup_failure(
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
) -> None:
monkeypatch.setattr(model_runtime_module, "redis_client", RedisClientWrapper())
model = make_model_instance(provider="invalid/provider", model="unavailable")
assert _default_suggested_questions_model_parameters(model) == {"max_tokens": 256, "temperature": 0.0}
assert "invalid/provider" in caplog.text
def test_token_transport_failure_propagates_before_best_effort_generation(
config_overrides: Callable[..., None], monkeypatch: pytest.MonkeyPatch
) -> None:
config_overrides(PLUGIN_BASED_TOKEN_COUNTING_ENABLED=True)
# HTTPX rejects an unsupported configured scheme itself, without a network
# request or a replaced transport. The real plugin client wraps that error.
monkeypatch.setattr(plugin_base, "plugin_daemon_inner_api_baseurl", URL("unsupported://plugin-daemon"))
history = PreparedHistory(
prompts=(
HistoryPrompt(
text="How does this work?",
is_user_message=True,
files=(),
tenant_id=str(uuid4()),
image_detail=ImagePromptMessageContent.DETAIL.HIGH,
),
)
)
context = SuggestedQuestionsContext(
app_id=str(uuid4()),
tenant_id=str(uuid4()),
app_mode="chat",
message_id=str(uuid4()),
conversation_id=str(uuid4()),
actor=MessageEndUser(end_user_id=str(uuid4())),
invoke_from="service-api",
config={"enabled": True},
)
metadata = get_credit_usage_metadata()
timeout = _get_plugin_daemon_request_timeout()
with pytest.raises(PluginDaemonInnerError) as failure:
SuggestedQuestionsGenerator().generate(
history,
history_model=make_model_instance(provider="langgenius/openai/openai", model="history-model"),
context=context,
instruction_prompt=None,
model_config=None,
)
assert isinstance(failure.value.__context__, httpx.UnsupportedProtocol)
assert get_credit_usage_metadata() == metadata
assert _get_plugin_daemon_request_timeout() == timeout
@pytest.mark.parametrize(
("text", "expected"),
[
('["Question 1?", "还有吗?"]', ["Question 1?", "还有吗?"]),
('Here are questions:\n["Next?"]', ["Next?"]),
('["Next?", 1, null, true]', ["Next?"]),
("[]", []),
("", []),
("not a question array", []),
('["unfinished]', []),
],
)
def test_response_parser_handles_question_arrays(text: str, expected: list[str]) -> None:
assert SuggestedQuestionsAfterAnswerOutputParser().parse(text) == expected
@pytest.mark.parametrize("instruction", [None, "", " "])
def test_empty_instruction_uses_default_prompt(instruction: str | None) -> None:
parser = SuggestedQuestionsAfterAnswerOutputParser(instruction_prompt=instruction)
assert parser.get_format_instructions() == DEFAULT_SUGGESTED_QUESTIONS_AFTER_ANSWER_INSTRUCTION_PROMPT
def test_custom_instruction_preserves_json_output_requirement() -> None:
instructions = SuggestedQuestionsAfterAnswerOutputParser("Ask a follow-up").get_format_instructions()
assert instructions.startswith("Ask a follow-up\n")
assert 'JSON array like ["question1", "question2", "question3"]' in instructions
@pytest.mark.parametrize("limit", [0, -1, -0.5])
def test_non_positive_token_limits_are_removed_without_mutating_configuration(limit: int | float) -> None:
configured: dict[str, object] = {
"temperature": 0.2,
"max_tokens": limit,
"max_output_tokens": limit,
"stop": ["END"],
}
parameters, stop = _normalize_completion_params(configured)
assert parameters == {"temperature": 0.2}
assert stop == ["END"]
assert configured == {"temperature": 0.2, "max_tokens": limit, "max_output_tokens": limit, "stop": ["END"]}
def test_configured_parameters_keep_positive_limits() -> None:
assert _normalize_completion_params({"max_tokens": 512, "max_output_tokens": 1024}) == (
{"max_tokens": 512, "max_output_tokens": 1024},
[],
)
@pytest.mark.parametrize("tracing", ["invalid trace JSON", '{"enabled":{"invalid":"type"}}'])
def test_trace_config_failure_escapes_generation_and_closes_its_sessions(
invalid_provider_tenant: str,
sqlite_session_factory: sessionmaker[Session],
tracing: str,
caplog: pytest.LogCaptureFixture,
) -> None:
app_record = make_app(app_id=str(uuid4()), tenant_id=invalid_provider_tenant)
app_record.tracing = tracing
with sqlite_session_factory.begin() as session:
session.add(app_record)
context = SuggestedQuestionsContext(
app_id=app_record.id,
tenant_id=invalid_provider_tenant,
app_mode="chat",
message_id=str(uuid4()),
conversation_id=str(uuid4()),
actor=MessageEndUser(end_user_id=str(uuid4())),
invoke_from="service-api",
config={"enabled": True},
)
caller = db.session()
provider = caller.scalars(select(Provider).where(Provider.tenant_id == invalid_provider_tenant)).one()
provider.quota_limit = 27
transaction = caller.get_transaction()
sessions: list[Session] = []
def record_session(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
sessions.append(session)
factory = db.session.session_factory
event.listen(factory, "after_begin", record_session)
try:
# Empty detached history needs no token-counting call. The real failed
# model lookup returns [], then trace construction reads the malformed
# stored config before it could create a timer or dispatch telemetry.
with pytest.raises(ValidationError), current_app.app_context():
SuggestedQuestionsGenerator().generate(
PreparedHistory(prompts=()),
history_model=make_model_instance(provider="invalid/provider", model="unused"),
context=context,
instruction_prompt=None,
model_config=None,
)
finally:
event.remove(factory, "after_begin", record_session)
assert "invalid/provider" in caplog.text
assert len(sessions) == 2
assert sessions[0] is not sessions[1]
assert all(not session.in_transaction() and not session.identity_map for session in sessions)
assert db.session() is caller
assert caller.get_transaction() is transaction
assert provider in caller.dirty
assert provider.quota_limit == 27
with sqlite_session_factory() as session:
persisted = session.get(Provider, provider.id)
assert persisted is not None
assert persisted.quota_limit is None
def test_prepare_isolates_a_caller_transaction_failed_by_a_real_constraint(
invalid_provider_tenant: str, caplog: pytest.LogCaptureFixture
) -> None:
context = SuggestedQuestionsContext(
app_id=str(uuid4()),
tenant_id=invalid_provider_tenant,
app_mode="chat",
message_id=str(uuid4()),
conversation_id=str(uuid4()),
actor=MessageEndUser(end_user_id=str(uuid4())),
invoke_from="service-api",
config={"enabled": True},
)
caller = db.session()
provider_id = caller.scalars(select(Provider.id).where(Provider.tenant_id == invalid_provider_tenant)).one()
duplicate = Provider(tenant_id=invalid_provider_tenant, provider_name="duplicate", is_valid=True)
duplicate.id = provider_id
caller.add(duplicate)
with pytest.raises(IntegrityError):
caller.flush()
failed_transaction = caller.get_transaction()
assert failed_transaction is not None
assert not caller.is_active
with SuggestedQuestionsGenerator().prepare(context=context, instruction_prompt=None, model_config=None) as generate:
assert generate is None
# Model resolution used an independent usable session. It must neither
# inherit the caller's failed transaction nor roll that transaction back.
assert "invalid/provider" in caplog.text
assert db.session() is caller
assert caller.get_transaction() is failed_transaction
assert not caller.is_active
with pytest.raises(PendingRollbackError):
caller.scalar(select(Provider.id))
caller.rollback()
assert caller.scalar(select(Provider.id).where(Provider.id == provider_id)) == provider_id
@@ -0,0 +1,834 @@
"""Shared service ownership, configuration selection, and session lifecycle."""
import json
from base64 import b64decode
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from decimal import Decimal
from pathlib import Path
from typing import Literal
from uuid import uuid4
import pytest
from flask import Flask
from sqlalchemy import Connection, Engine, event, update
from sqlalchemy.orm import Session, SessionTransaction, sessionmaker
from core.app.entities.app_invoke_entities import InvokeFrom, UserFrom
from core.app.file_access import FileAccessScope, bind_file_access_scope
from core.memory.token_buffer_memory import PreparedHistory
from extensions.ext_database import db
from extensions.ext_storage import storage
from extensions.storage.opendal_storage import OpenDALStorage
from extensions.storage.storage_type import StorageType
from graphon.file import FileTransferMethod, FileType
from libs.datetime_utils import naive_utc_now
from models import Account, App, AppMode, Conversation, Message
from models.agent import (
Agent,
AgentConfigDraft,
AgentConfigDraftType,
AgentConfigSnapshot,
AgentConfigVersionKind,
AgentDebugConversation,
AgentScope,
AgentSource,
AgentWorkspaceBinding,
)
from models.agent_config_entities import AgentSoulConfig
from models.enums import ConversationFromSource, CreatorUserRole, EndUserType
from models.model import AppModelConfig, EndUser, MessageFile, UploadFile
from models.workflow import Workflow, WorkflowType
from repositories.message_repository import MessageRepository
from services.app_definition_query_service import AppDefinitionUnavailableError
from services.entities.message_entities import MessageAccount, MessageActor, MessageEndUser
from services.errors.conversation import ConversationNotExistsError
from services.errors.message import (
MessageActorNotFoundError,
MessageNotExistsError,
SuggestedQuestionsAfterAnswerDisabledError,
)
from services.message_suggested_questions_generator import SuggestedQuestionsGenerator
from services.message_suggested_questions_queries import SuggestedQuestionsQuery
from services.message_suggested_questions_service import (
MessageSuggestedQuestionsService,
SuggestedQuestionsContext,
SuggestedQuestionsInvokeFrom,
)
from tests.helpers.unit.core.model_fixtures import make_model_instance
@dataclass(frozen=True)
class _Harness:
flask_app: Flask
target: App
account: Account
end_user: EndUser
conversation: Conversation
message: Message
factory: sessionmaker[Session]
queries: SuggestedQuestionsQuery
service: MessageSuggestedQuestionsService[PreparedHistory]
read_sessions: list[Session]
scoped_sessions: list[Session]
def actor(self, invoke_from: SuggestedQuestionsInvokeFrom) -> MessageActor:
if invoke_from in {"explore", "debugger"}:
with self.factory.begin() as session:
for model, record_id in ((Message, self.message.id), (Conversation, self.conversation.id)):
session.execute(
update(model)
.where(model.id == record_id)
.values(
from_source=ConversationFromSource.CONSOLE,
from_account_id=self.account.id,
from_end_user_id=None,
)
)
return MessageAccount(account_id=self.account.id)
return MessageEndUser(end_user_id=self.end_user.id)
def get(self, actor: MessageActor, *, invoke_from: SuggestedQuestionsInvokeFrom) -> list[str]:
return self.service.get_suggested_questions(
app_id=self.target.id,
app_owner_tenant_id=self.target.tenant_id,
expected_app_mode=self.target.mode,
actor=actor,
invoke_from=invoke_from,
message_id=self.message.id,
)
def prepare(self, actor: MessageActor, *, invoke_from: SuggestedQuestionsInvokeFrom) -> SuggestedQuestionsContext:
context = self.queries.prepare(
app_id=self.target.id,
app_owner_tenant_id=self.target.tenant_id,
expected_app_mode=self.target.mode,
actor=actor,
invoke_from=invoke_from,
message_id=self.message.id,
)
assert context is not None
return context
def set_mode(self, mode: AppMode) -> None:
with self.factory.begin() as session:
session.execute(update(App).where(App.id == self.target.id).values(mode=mode))
session.execute(update(Conversation).where(Conversation.id == self.conversation.id).values(mode=mode))
self.target.mode = mode
def assert_closed(self) -> None:
assert all(
not session.in_transaction() and not session.identity_map
for session in self.read_sessions + self.scoped_sessions
)
@pytest.fixture
def harness(
sqlite_engine: Engine,
sqlite_session_factory: sessionmaker[Session],
) -> Iterator[_Harness]:
target = App(
id=str(uuid4()), tenant_id=str(uuid4()), name="Shared", mode=AppMode.CHAT, enable_site=True, enable_api=True
)
account = Account(name="Debugger", email="debugger@example.com")
end_user = EndUser(
tenant_id=target.tenant_id, app_id=target.id, type=EndUserType.SERVICE_API, session_id=str(uuid4())
)
config = AppModelConfig(
app_id=target.id, suggested_questions_after_answer='{"enabled":true,"prompt":"Published questions"}'
)
with sqlite_session_factory.begin() as session:
session.add_all([target, account, end_user, config])
session.flush()
target.app_model_config_id = config.id
conversation = Conversation(
app_id=target.id,
app_model_config_id=config.id,
mode=AppMode.CHAT,
name="End-user conversation",
_inputs={},
from_source=ConversationFromSource.API,
from_end_user_id=end_user.id,
)
session.add(conversation)
session.flush()
message = Message(
app_id=target.id,
conversation_id=conversation.id,
_inputs={},
query="How does this work?",
message={},
message_unit_price=Decimal(0),
answer="Like this.",
answer_unit_price=Decimal(0),
currency="USD",
from_source=ConversationFromSource.API,
from_end_user_id=end_user.id,
)
session.add(message)
read_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
read_sessions: list[Session] = []
@event.listens_for(read_factory, "after_begin")
def track_read(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
read_sessions.append(session)
flask_app = Flask(__name__)
flask_app.config.update(TESTING=True, SQLALCHEMY_DATABASE_URI=str(sqlite_engine.url))
db.init_app(flask_app)
scoped_sessions: list[Session] = []
def track_scoped(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
scoped_sessions.append(session)
event.listen(db.session.session_factory, "after_begin", track_scoped)
queries = SuggestedQuestionsQuery(session_factory=read_factory, repository_factory=MessageRepository)
with flask_app.app_context():
yield _Harness(
flask_app,
target,
account,
end_user,
conversation,
message,
sqlite_session_factory,
queries,
MessageSuggestedQuestionsService(queries=queries, generator=SuggestedQuestionsGenerator()),
read_sessions,
scoped_sessions,
)
event.remove(db.session.session_factory, "after_begin", track_scoped)
with flask_app.app_context():
db.session.remove()
db.engine.dispose()
@pytest.mark.parametrize("change", ["app_id", "tenant", "mode", "account"])
def test_service_rejects_stale_reference_before_model_queries(
harness: _Harness, change: Literal["app_id", "tenant", "mode", "account"]
) -> None:
account_id = str(uuid4()) if change == "account" else harness.account.id
app_id = str(uuid4()) if change == "app_id" else harness.target.id
error_type = MessageActorNotFoundError if change == "account" else AppDefinitionUnavailableError
with pytest.raises(error_type, match=account_id if change == "account" else app_id):
harness.service.get_suggested_questions(
app_id=app_id,
app_owner_tenant_id=str(uuid4()) if change == "tenant" else harness.target.tenant_id,
expected_app_mode=AppMode.ADVANCED_CHAT if change == "mode" else harness.target.mode,
actor=MessageAccount(account_id=account_id),
invoke_from="explore",
message_id=harness.message.id,
)
assert len(harness.read_sessions) == 1
assert not harness.scoped_sessions
harness.assert_closed()
@pytest.mark.parametrize("change", ["missing", "tenant", "app"])
def test_end_user_reload_requires_the_admitted_app_owner(
harness: _Harness, change: Literal["missing", "tenant", "app"]
) -> None:
end_user_id = str(uuid4()) if change == "missing" else harness.end_user.id
if change != "missing":
values = {"tenant_id" if change == "tenant" else "app_id": str(uuid4())}
with harness.factory.begin() as session:
session.execute(update(EndUser).where(EndUser.id == end_user_id).values(values))
with pytest.raises(MessageActorNotFoundError, match=end_user_id):
harness.get(MessageEndUser(end_user_id=end_user_id), invoke_from="service-api")
assert not harness.scoped_sessions
harness.assert_closed()
@pytest.mark.parametrize("invoke_from", ["debugger", "explore", "web-app", "service-api"])
def test_queries_return_detached_configuration_and_history_without_changing_caller_session(
harness: _Harness, invoke_from: SuggestedQuestionsInvokeFrom, config_overrides: Callable[..., None]
) -> None:
# Exercise the production path for deployments with plugin token counting disabled.
config_overrides(PLUGIN_BASED_TOKEN_COUNTING_ENABLED=False)
actor = harness.actor(invoke_from)
feature = {
"enabled": True,
"prompt": "Published questions",
"model": {"provider": "vendor", "name": "question-model"},
}
with harness.factory.begin() as session:
session.execute(
update(AppModelConfig)
.where(AppModelConfig.id == harness.target.app_model_config_id)
.values(suggested_questions_after_answer=json.dumps(feature))
)
with harness.flask_app.app_context():
caller_session = db.session()
caller_app = caller_session.get(App, harness.target.id)
assert caller_app is not None
caller_app.name = "Pending caller edit"
harness.scoped_sessions.clear()
context = harness.prepare(actor, invoke_from=invoke_from)
assert context.app_id == harness.target.id
assert context.tenant_id == harness.target.tenant_id
assert context.app_mode == AppMode.CHAT
assert context.message_id == harness.message.id
assert context.conversation_id == harness.conversation.id
assert context.actor == actor
assert context.invoke_from == invoke_from
assert context.config == feature
assert len(harness.read_sessions) == 1
harness.assert_closed()
history = harness.queries.load_history(context)
assert [(prompt.text, prompt.is_user_message) for prompt in history.prompts] == [
("How does this work?", True),
("Like this.", False),
]
assert all(not prompt.files for prompt in history.prompts)
assert all(prompt.tenant_id == harness.target.tenant_id for prompt in history.prompts)
assert len(harness.read_sessions) == 2
assert harness.read_sessions[0] is not harness.read_sessions[1]
harness.assert_closed()
model = make_model_instance(provider="langgenius/openai/openai", model="history-model")
assert history.get_prompt_text(model_instance=model, max_token_limit=3000) == (
"Human: How does this work?\nAssistant: Like this."
)
harness.assert_closed()
assert db.session() is caller_session
assert caller_session.in_transaction()
assert caller_app in caller_session.dirty
assert caller_app.name == "Pending caller edit"
assert not harness.scoped_sessions
# The query did not commit the caller's pending edit, and the returned
# configuration and history no longer depend on the persisted rows.
with harness.factory.begin() as session:
persisted_app = session.get(App, harness.target.id)
assert persisted_app is not None
assert persisted_app.name == "Shared"
session.execute(
update(AppModelConfig)
.where(AppModelConfig.id == harness.target.app_model_config_id)
.values(suggested_questions_after_answer='{"enabled":false}')
)
session.execute(update(Message).where(Message.id == harness.message.id).values(answer="Changed answer"))
assert context.config == feature
assert history.prompts[-1].text == "Like this."
@pytest.mark.parametrize("invoke_from", ["explore", "service-api"])
@pytest.mark.parametrize("entity", ["message", "conversation"])
@pytest.mark.parametrize("change", ["id", "app_id", "from_account_id", "from_end_user_id", "from_source"])
def test_message_and_conversation_ownership_are_enforced(
harness: _Harness,
invoke_from: Literal["explore", "service-api"],
entity: Literal["message", "conversation"],
change: Literal["id", "app_id", "from_account_id", "from_end_user_id", "from_source"],
) -> None:
actor = harness.actor(invoke_from)
model = Message if entity == "message" else Conversation
record_id = harness.message.id if entity == "message" else harness.conversation.id
if change == "from_source":
value = ConversationFromSource.API if invoke_from == "explore" else ConversationFromSource.CONSOLE
else:
value = str(uuid4())
with harness.factory.begin() as session:
session.execute(update(model).where(model.id == record_id).values({change: value}))
error_type = MessageNotExistsError if entity == "message" else ConversationNotExistsError
with harness.flask_app.app_context(), pytest.raises(error_type):
harness.get(actor, invoke_from=invoke_from)
harness.assert_closed()
@pytest.mark.parametrize("invoke_from", ["explore", "web-app"])
def test_actor_cannot_read_a_deleted_conversation(
harness: _Harness, invoke_from: Literal["explore", "web-app"]
) -> None:
actor = harness.actor(invoke_from)
with harness.factory.begin() as session:
session.execute(update(Conversation).where(Conversation.id == harness.conversation.id).values(is_deleted=True))
with harness.flask_app.app_context(), pytest.raises(ConversationNotExistsError):
harness.get(actor, invoke_from=invoke_from)
harness.assert_closed()
@pytest.mark.parametrize("invoke_from", ["explore", "service-api"])
@pytest.mark.parametrize(
"change", ["tenant", "mode", "app_id", "from_account_id", "from_end_user_id", "from_source", "deleted"]
)
def test_history_revalidates_app_and_conversation_after_preparing_context(
harness: _Harness,
invoke_from: Literal["explore", "service-api"],
change: Literal["tenant", "mode", "app_id", "from_account_id", "from_end_user_id", "from_source", "deleted"],
) -> None:
context = harness.queries.prepare(
app_id=harness.target.id,
app_owner_tenant_id=harness.target.tenant_id,
expected_app_mode=harness.target.mode,
actor=harness.actor(invoke_from),
invoke_from=invoke_from,
message_id=harness.message.id,
)
assert context is not None
harness.assert_closed()
with harness.factory.begin() as session:
if change in {"tenant", "mode"}:
values = {"tenant_id": str(uuid4())} if change == "tenant" else {"mode": AppMode.ADVANCED_CHAT}
session.execute(update(App).where(App.id == harness.target.id).values(values))
else:
conversation_values: dict[str, object]
if change == "deleted":
conversation_values = {"is_deleted": True}
elif change == "from_source":
conversation_values = {
"from_source": ConversationFromSource.API
if invoke_from == "explore"
else ConversationFromSource.CONSOLE
}
else:
conversation_values = {change: str(uuid4())}
session.execute(
update(Conversation).where(Conversation.id == harness.conversation.id).values(conversation_values)
)
error_type = AppDefinitionUnavailableError if change in {"tenant", "mode"} else ConversationNotExistsError
with pytest.raises(error_type):
harness.queries.load_history(context)
assert len(harness.read_sessions) == 2
harness.assert_closed()
@pytest.mark.parametrize("case", ["disabled", "missing_workflow"])
def test_account_preserves_disabled_feature_and_missing_workflow_behavior(
harness: _Harness, case: Literal["disabled", "missing_workflow"]
) -> None:
actor = harness.actor("explore")
with harness.factory.begin() as session:
if case == "disabled":
session.execute(
update(AppModelConfig)
.where(AppModelConfig.id == harness.target.app_model_config_id)
.values(suggested_questions_after_answer='{"enabled":false}')
)
else:
session.execute(update(App).where(App.id == harness.target.id).values(mode=AppMode.ADVANCED_CHAT))
harness.target.mode = AppMode.ADVANCED_CHAT
if case == "disabled":
with pytest.raises(SuggestedQuestionsAfterAnswerDisabledError):
harness.get(actor, invoke_from="explore")
else:
assert harness.get(actor, invoke_from="explore") == []
# Both branches finish before model resolution and the separate history query.
assert not harness.scoped_sessions
assert len(harness.read_sessions) == 1
harness.assert_closed()
@pytest.mark.parametrize("invoke_from", ["debugger", "explore"])
def test_advanced_chat_debugger_uses_draft_while_explore_uses_published_workflow(
harness: _Harness, invoke_from: Literal["debugger", "explore"]
) -> None:
actor = harness.actor(invoke_from)
with harness.factory.begin() as session:
for version, prompt in ((Workflow.VERSION_DRAFT, "Draft questions"), ("published", "Published questions")):
workflow = Workflow(
tenant_id=harness.target.tenant_id,
app_id=harness.target.id,
type=WorkflowType.CHAT,
version=version,
graph='{"nodes":[],"edges":[]}',
features=json.dumps({"suggested_questions_after_answer": {"enabled": True, "prompt": prompt}}),
created_by=harness.account.id,
)
session.add(workflow)
session.flush()
if version != Workflow.VERSION_DRAFT:
session.execute(update(App).where(App.id == harness.target.id).values({App.workflow_id: workflow.id}))
session.execute(update(App).where(App.id == harness.target.id).values(mode=AppMode.ADVANCED_CHAT))
session.execute(
update(Conversation).where(Conversation.id == harness.conversation.id).values(mode=AppMode.ADVANCED_CHAT)
)
harness.target.mode = AppMode.ADVANCED_CHAT
context = harness.prepare(actor, invoke_from=invoke_from)
expected_prompt = "Draft questions" if invoke_from == "debugger" else "Published questions"
assert context.config == {"enabled": True, "prompt": expected_prompt}
assert context.app_mode == AppMode.ADVANCED_CHAT
harness.assert_closed()
def test_history_detaches_remote_attachment_references_before_rendering(harness: _Harness) -> None:
actor = harness.actor("explore")
attachment = MessageFile(
message_id=harness.message.id,
type=FileType.IMAGE,
transfer_method=FileTransferMethod.REMOTE_URL,
url="https://example.com/history.png",
created_by_role=CreatorUserRole.ACCOUNT,
created_by=harness.account.id,
)
with harness.factory.begin() as session:
session.execute(
update(AppModelConfig)
.where(AppModelConfig.id == harness.target.app_model_config_id)
.values(
file_upload=json.dumps(
{"enabled": True, "allowed_file_types": ["image"], "allowed_file_upload_methods": ["remote_url"]}
)
)
)
session.add(attachment)
context = harness.prepare(actor, invoke_from="explore")
history = harness.queries.load_history(context)
harness.assert_closed()
assert len(harness.read_sessions) == 2
user_prompt, assistant_prompt = history.prompts
assert user_prompt.text == "How does this work?"
assert user_prompt.tenant_id == harness.target.tenant_id
assert len(user_prompt.files) == 1
reference = user_prompt.files[0]
assert reference.id == attachment.id
assert reference.type == FileType.IMAGE
assert reference.transfer_method == FileTransferMethod.REMOTE_URL
assert reference.url == attachment.url
assert reference.upload_file_id is None
assert not assistant_prompt.files
@pytest.mark.parametrize("case", ["missing-record", "other-owner", "missing-content", "available"])
def test_attachment_restoration_closes_sessions_for_real_file_failures(
harness: _Harness,
case: Literal["missing-record", "other-owner", "missing-content", "available"],
config_overrides: Callable[..., None],
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
config_overrides(PLUGIN_BASED_TOKEN_COUNTING_ENABLED=False, MULTIMODAL_SEND_FORMAT="base64")
local_storage = OpenDALStorage(scheme="fs", root=str(tmp_path))
# Bind an actual filesystem backend; file building, authorization and reads
# all keep their production implementations.
monkeypatch.setattr(storage, "storage_runner", local_storage, raising=False)
image = b64decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+xU5sAAAAASUVORK5CYII=")
upload = UploadFile(
tenant_id=harness.target.tenant_id,
storage_type=StorageType.LOCAL,
key="history/image.png",
name="image.png",
size=len(image),
extension="png",
mime_type="image/png",
created_by_role=CreatorUserRole.END_USER,
created_by=str(uuid4()) if case == "other-owner" else harness.end_user.id,
created_at=naive_utc_now(),
used=True,
)
if case != "missing-content":
local_storage.save(upload.key, image)
with harness.factory.begin() as session:
session.execute(
update(AppModelConfig)
.where(AppModelConfig.id == harness.target.app_model_config_id)
.values(
file_upload=json.dumps(
{"enabled": True, "allowed_file_types": ["image"], "allowed_file_upload_methods": ["local_file"]}
)
)
)
if case != "missing-record":
session.add(upload)
session.add(
MessageFile(
message_id=harness.message.id,
type=FileType.IMAGE,
transfer_method=FileTransferMethod.LOCAL_FILE,
upload_file_id=upload.id,
created_by_role=CreatorUserRole.END_USER,
created_by=harness.end_user.id,
)
)
context = harness.prepare(harness.actor("web-app"), invoke_from="web-app")
history = harness.queries.load_history(context)
assert len(harness.read_sessions) == 2
harness.assert_closed()
model = make_model_instance(provider="langgenius/openai/openai", model="history-model")
file_sessions: list[Session] = []
def track_file_read(session: Session, _transaction: SessionTransaction, _connection: Connection) -> None:
harness.assert_closed()
file_sessions.append(session)
scope = FileAccessScope(
tenant_id=harness.target.tenant_id,
user_id=harness.end_user.id,
user_from=UserFrom.END_USER,
invoke_from=InvokeFrom.WEB_APP,
)
event.listen(harness.factory, "after_begin", track_file_read)
try:
with bind_file_access_scope(scope):
if case == "available":
assert history.get_prompt_text(model_instance=model, max_token_limit=3000) == (
"Human: [image]\nHow does this work?\nAssistant: Like this."
)
else:
error = FileNotFoundError if case == "missing-content" else ValueError
detail = "File not found" if case == "missing-content" else "Invalid upload file"
with pytest.raises(error, match=detail):
history.get_prompt_text(model_instance=model, max_token_limit=3000)
finally:
event.remove(harness.factory, "after_begin", track_file_read)
assert len(file_sessions) == (2 if case in {"missing-content", "available"} else 1)
assert all(not session.in_transaction() and not session.identity_map for session in file_sessions)
assert not harness.scoped_sessions
harness.assert_closed()
def test_configuration_error_closes_query_session_and_preserves_caller_transaction(harness: _Harness) -> None:
actor = harness.actor("explore")
with harness.factory.begin() as session:
session.execute(
update(AppModelConfig)
.where(AppModelConfig.id == harness.target.app_model_config_id)
.values(suggested_questions_after_answer="{")
)
with harness.flask_app.app_context():
caller_session = db.session()
caller_app = caller_session.get(App, harness.target.id)
assert caller_app is not None
caller_app.name = "Pending caller edit"
harness.scoped_sessions.clear()
with pytest.raises(json.JSONDecodeError):
harness.get(actor, invoke_from="explore")
assert len(harness.read_sessions) == 1
harness.assert_closed()
assert db.session() is caller_session
assert caller_session.in_transaction()
assert caller_app in caller_session.dirty
assert caller_app.name == "Pending caller edit"
assert not harness.scoped_sessions
with harness.factory() as session:
persisted_app = session.get(App, harness.target.id)
assert persisted_app is not None
assert persisted_app.name == "Shared"
def _agent_soul(prompt: str, *, enabled: bool = True, model: dict[str, object] | None = None) -> AgentSoulConfig:
feature: dict[str, object] = {"enabled": enabled, "prompt": prompt}
if model is not None:
feature["model"] = model
return AgentSoulConfig.model_validate({"app_features": {"suggested_questions_after_answer": feature}})
def _published_agent(harness: _Harness, *, enabled: bool = True) -> Agent:
agent = Agent(
tenant_id=harness.target.tenant_id,
app_id=harness.target.id,
name="Published agent",
scope=AgentScope.ROSTER,
source=AgentSource.AGENT_APP,
)
with harness.factory.begin() as session:
session.add(agent)
session.flush()
snapshot = AgentConfigSnapshot(
tenant_id=agent.tenant_id,
agent_id=agent.id,
version=1,
config_snapshot=_agent_soul("Current agent questions", enabled=enabled),
)
session.add(snapshot)
session.flush()
agent.active_config_snapshot_id = snapshot.id
return agent
@pytest.mark.parametrize("draft_type", [AgentConfigDraftType.DRAFT, AgentConfigDraftType.DEBUG_BUILD])
def test_agent_debugger_uses_matching_draft_with_custom_model(
harness: _Harness, draft_type: AgentConfigDraftType
) -> None:
harness.set_mode(AppMode.AGENT)
actor = harness.actor("debugger")
agent = _published_agent(harness)
model: dict[str, object] = {"provider": "vendor", "name": "draft-model", "completion_params": {"temperature": 0.1}}
with harness.factory.begin() as session:
session.add_all(
[
AgentConfigDraft(
tenant_id=harness.target.tenant_id,
agent_id=agent.id,
draft_type=draft_type,
account_id=harness.account.id if draft_type == AgentConfigDraftType.DEBUG_BUILD else None,
draft_owner_key=harness.account.id if draft_type == AgentConfigDraftType.DEBUG_BUILD else "",
config_snapshot=_agent_soul("Matching draft questions", model=model),
),
AgentDebugConversation(
tenant_id=harness.target.tenant_id,
agent_id=agent.id,
app_id=harness.target.id,
account_id=harness.account.id,
draft_type=draft_type,
conversation_id=harness.conversation.id,
),
]
)
session.execute(
update(AppModelConfig)
.where(AppModelConfig.id == harness.target.app_model_config_id)
.values(suggested_questions_after_answer='{"enabled":false}')
)
context = harness.prepare(actor, invoke_from="debugger")
assert context.config == {"enabled": True, "prompt": "Matching draft questions", "model": model}
harness.assert_closed()
@pytest.mark.parametrize("version_kind", [AgentConfigVersionKind.SNAPSHOT, AgentConfigVersionKind.DRAFT])
def test_agent_conversation_uses_bound_config_before_current_published_config(
harness: _Harness, version_kind: AgentConfigVersionKind
) -> None:
harness.set_mode(AppMode.AGENT)
agent = _published_agent(harness)
with harness.factory.begin() as session:
config: AgentConfigSnapshot | AgentConfigDraft
if version_kind == AgentConfigVersionKind.SNAPSHOT:
config = AgentConfigSnapshot(
tenant_id=harness.target.tenant_id,
agent_id=agent.id,
version=2,
config_snapshot=_agent_soul("Bound agent questions"),
)
else:
config = AgentConfigDraft(
tenant_id=harness.target.tenant_id,
agent_id=agent.id,
draft_type=AgentConfigDraftType.DRAFT,
config_snapshot=_agent_soul("Bound agent questions"),
)
session.add(config)
session.flush()
binding = AgentWorkspaceBinding(
tenant_id=harness.target.tenant_id,
app_id=harness.target.id,
workspace_id=str(uuid4()),
agent_id=agent.id,
agent_config_version_id=config.id,
agent_config_version_kind=version_kind,
backend_binding_ref="bound-agent",
)
session.add(binding)
session.flush()
session.execute(
update(Conversation)
.where(Conversation.id == harness.conversation.id)
.values(agent_workspace_binding_id=binding.id)
)
context = harness.prepare(harness.actor("service-api"), invoke_from="service-api")
assert context.config == {"enabled": True, "prompt": "Bound agent questions"}
harness.assert_closed()
@pytest.mark.parametrize("case", ["published", "legacy", "disabled"])
def test_agent_published_config_and_legacy_fallback(
harness: _Harness, case: Literal["published", "legacy", "disabled"]
) -> None:
harness.set_mode(AppMode.AGENT)
if case != "legacy":
_published_agent(harness, enabled=case != "disabled")
actor = harness.actor("service-api")
if case == "disabled":
with pytest.raises(SuggestedQuestionsAfterAnswerDisabledError):
harness.get(actor, invoke_from="service-api")
else:
context = harness.prepare(actor, invoke_from="service-api")
expected_prompt = "Published questions" if case == "legacy" else "Current agent questions"
assert context.config == {"enabled": True, "prompt": expected_prompt}
harness.assert_closed()
@pytest.mark.parametrize("use_override", [False, True])
def test_chat_uses_conversation_config_and_custom_completion_parameters(harness: _Harness, use_override: bool) -> None:
feature = {
"enabled": True,
"prompt": "Conversation questions",
"model": {"provider": "vendor", "name": "conversation-model", "completion_params": {"temperature": 0.2}},
}
with harness.factory.begin() as session:
if use_override:
override = {
"model": {"provider": "vendor", "name": "chat-model", "mode": "chat"},
"suggested_questions_after_answer": feature,
}
session.execute(
update(Conversation)
.where(Conversation.id == harness.conversation.id)
.values({Conversation.override_model_configs: json.dumps(override)})
)
else:
session.execute(
update(AppModelConfig)
.where(AppModelConfig.id == harness.target.app_model_config_id)
.values(suggested_questions_after_answer=json.dumps(feature))
)
current_config = AppModelConfig(app_id=harness.target.id, suggested_questions_after_answer='{"enabled":false}')
session.add(current_config)
session.flush()
session.execute(
update(App).where(App.id == harness.target.id).values({App.app_model_config_id: current_config.id})
)
context = harness.prepare(harness.actor("web-app"), invoke_from="web-app")
assert context.config == feature
harness.assert_closed()
@pytest.mark.parametrize(
"file_upload",
[
None,
{"image": None},
{"image": {"enabled": False}},
{"image": {"enabled": True, "number_limits": 3, "transfer_methods": ["remote_url"]}},
],
ids=["null-upload", "null-image", "disabled-image", "legacy-image"],
)
def test_workflow_compatibility_normalization_does_not_dirty_or_write_rows(
harness: _Harness, file_upload: dict[str, object] | None
) -> None:
harness.set_mode(AppMode.ADVANCED_CHAT)
actor = harness.actor("explore")
features = json.dumps(
{
"suggested_questions_after_answer": {"enabled": True, "prompt": "Legacy workflow questions"},
"file_upload": file_upload,
}
)
with harness.factory.begin() as session:
workflow = Workflow(
tenant_id=harness.target.tenant_id,
app_id=harness.target.id,
type=WorkflowType.CHAT,
version="published",
graph='{"nodes":[],"edges":[]}',
features=features,
created_by=harness.account.id,
)
session.add(workflow)
session.flush()
session.execute(update(App).where(App.id == harness.target.id).values({App.workflow_id: workflow.id}))
dirty_workflows: list[str] = []
def record_changes(workflow: Workflow, _value: object, _old_value: object, _initiator: object) -> None:
dirty_workflows.append(workflow.id)
# Even a rolled-back assignment would leave a supposedly read-only query dirty.
event.listen(Workflow._features, "set", record_changes)
try:
context = harness.prepare(actor, invoke_from="explore")
finally:
event.remove(Workflow._features, "set", record_changes)
assert not dirty_workflows
with harness.factory() as session:
persisted = session.get(Workflow, workflow.id)
assert persisted is not None
assert persisted.serialized_features == features
assert context.config == {"enabled": True, "prompt": "Legacy workflow questions"}
harness.assert_closed()
@@ -620,6 +620,7 @@ export const get24Errors = { app_not_found: { status: 404, data: zAppNotFoundErr
export const get25Errors = { app_not_found: { status: 404, data: zAppNotFoundError } }
export const get26Errors = {
agent_version_not_found_error: { status: 404, data: zAgentVersionNotFoundError },
app_not_found: { status: 404, data: zAppNotFoundError },
app_suggested_questions_after_answer_disabled: {
status: 403,
@@ -1764,6 +1764,7 @@ export const get26 = oc
.input(z.object({ params: zGetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsPath }))
.output(zGetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsResponse)
.errors({
agent_version_not_found_error: { status: 404, data: zAgentVersionNotFoundError },
app_not_found: { status: 404, data: zAppNotFoundError },
app_suggested_questions_after_answer_disabled: {
status: 403,
+1 -1
View File
@@ -6169,7 +6169,7 @@ export type GetAppsByAppIdChatMessagesByMessageIdSuggestedQuestionsErrors = {
| ErrorResponse
401: ErrorResponse
403: AppSuggestedQuestionsAfterAnswerDisabledError | ErrorResponse
404: AppNotFoundError | ErrorResponse
404: AgentVersionNotFoundError | AppNotFoundError | ErrorResponse
500: ErrorResponse
default: ErrorResponse
}
@@ -436,6 +436,7 @@ export const post5Errors = {
}
export const get13Errors = {
agent_version_not_found_error: { status: 404, data: zAgentVersionNotFoundError },
app_suggested_questions_after_answer_disabled: {
status: 403,
data: zAppSuggestedQuestionsAfterAnswerDisabledError,
@@ -596,6 +596,7 @@ export const get13 = oc
.input(z.object({ params: zGetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsPath }))
.output(zGetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsResponse)
.errors({
agent_version_not_found_error: { status: 404, data: zAgentVersionNotFoundError },
app_suggested_questions_after_answer_disabled: {
status: 403,
data: zAppSuggestedQuestionsAfterAnswerDisabledError,
@@ -2059,6 +2059,7 @@ export type GetTrialAppsByAppIdMessagesByMessageIdSuggestedQuestionsErrors = {
| TrialAppLimitExceededError
| TrialAppNotAllowedError
| ErrorResponse
404: AgentVersionNotFoundError | ErrorResponse
default: ErrorResponse
}
+74 -1
View File
@@ -18,6 +18,46 @@ const zAgentNotPublishedError = /* @__PURE__ */ (() =>
}),
))()
const zAgentVersionNotFoundError = /* @__PURE__ */ (() =>
zErrorResponse.and(
z.object({
code: z.literal('agent_version_not_found_error'),
status: z.literal(404),
}),
))()
const zAppAbnormalStatusError = /* @__PURE__ */ (() =>
zErrorResponse.and(
z.object({
code: z.literal('app_abnormal_status'),
status: z.literal(403),
}),
))()
const zAppApiDisabledError = /* @__PURE__ */ (() =>
zErrorResponse.and(
z.object({
code: z.literal('app_api_disabled'),
status: z.literal(403),
}),
))()
const zAppNotFoundError = /* @__PURE__ */ (() =>
zErrorResponse.and(
z.object({
code: z.literal('app_not_found'),
status: z.literal(403),
}),
))()
const zAppSuggestedQuestionsAfterAnswerDisabledError = /* @__PURE__ */ (() =>
zErrorResponse.and(
z.object({
code: z.literal('app_suggested_questions_after_answer_disabled'),
status: z.literal(403),
}),
))()
const zAppUnavailableError = /* @__PURE__ */ (() =>
zErrorResponse.and(
z.object({
@@ -354,6 +394,22 @@ const zWorkflowVersionExecutionNotAllowedError = /* @__PURE__ */ (() =>
}),
))()
const zWorkspaceArchivedError = /* @__PURE__ */ (() =>
zErrorResponse.and(
z.object({
code: z.literal('workspace_archived'),
status: z.literal(403),
}),
))()
const zWorkspaceNotFoundError = /* @__PURE__ */ (() =>
zErrorResponse.and(
z.object({
code: z.literal('workspace_not_found'),
status: z.literal(403),
}),
))()
export const post3Errors = {
app_unavailable: { status: 400, data: zAppUnavailableError },
audio_too_large: { status: 413, data: zAudioTooLargeError },
@@ -1185,7 +1241,24 @@ export const get60Errors = {
knowledge_fs_upstream_unavailable: { status: 502, data: zKnowledgeFsUpstreamUnavailableError },
}
export const get61Errors = { not_chat_app: { status: 400, data: zNotChatAppError } }
export const get61Errors = {
agent_version_not_found_error: { status: 404, data: zAgentVersionNotFoundError },
app_abnormal_status: { status: 403, data: zAppAbnormalStatusError },
app_api_disabled: { status: 403, data: zAppApiDisabledError },
app_not_found: { status: 403, data: zAppNotFoundError },
app_suggested_questions_after_answer_disabled: {
status: 403,
data: zAppSuggestedQuestionsAfterAnswerDisabledError,
},
app_unavailable: { status: 400, data: zAppUnavailableError },
completion_request_error: { status: 400, data: zCompletionRequestError },
model_currently_not_support: { status: 400, data: zModelCurrentlyNotSupportError },
not_chat_app: { status: 400, data: zNotChatAppError },
provider_not_initialize: { status: 400, data: zProviderNotInitializeError },
provider_quota_exceeded: { status: 400, data: zProviderQuotaExceededError },
workspace_archived: { status: 403, data: zWorkspaceArchivedError },
workspace_not_found: { status: 403, data: zWorkspaceNotFoundError },
}
export const get62Errors = { not_chat_app: { status: 400, data: zNotChatAppError } }
+28 -3
View File
@@ -4,6 +4,11 @@ import { oc } from '@orpc/contract'
import * as z from 'zod'
import {
zAgentNotPublishedError,
zAgentVersionNotFoundError,
zAppAbnormalStatusError,
zAppApiDisabledError,
zAppNotFoundError,
zAppSuggestedQuestionsAfterAnswerDisabledError,
zAppUnavailableError,
zArchivedDocumentImmutableError,
zAudioTooLargeError,
@@ -46,6 +51,8 @@ import {
zUnsupportedAudioTypeError,
zUnsupportedFileTypeError,
zWorkflowVersionExecutionNotAllowedError,
zWorkspaceArchivedError,
zWorkspaceNotFoundError,
} from './zod.gen.ts'
import {
zDeleteAppsAnnotationsByAnnotationIdPath,
@@ -4729,11 +4736,12 @@ export const feedbacks2 = {
/**
* Get Next Suggested Questions
*
* Get next questions suggestions for the current message.
* Get next question suggestions for the current message. If no usable model can be resolved or the model call to generate questions fails, the response is HTTP 200 with an empty data list. Model invocation failures during history token counting instead return HTTP 400 with `completion_request_error`.
*/
export const get61 = oc
.route({
description: 'Get next questions suggestions for the current message.',
description:
'Get next question suggestions for the current message. If no usable model can be resolved or the model call to generate questions fails, the response is HTTP 200 with an empty data list. Model invocation failures during history token counting instead return HTTP 400 with `completion_request_error`.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getMessagesByMessageIdSuggested',
@@ -4748,7 +4756,24 @@ export const get61 = oc
}),
)
.output(zGetMessagesByMessageIdSuggestedResponse)
.errors({ not_chat_app: { status: 400, data: zNotChatAppError } })
.errors({
agent_version_not_found_error: { status: 404, data: zAgentVersionNotFoundError },
app_abnormal_status: { status: 403, data: zAppAbnormalStatusError },
app_api_disabled: { status: 403, data: zAppApiDisabledError },
app_not_found: { status: 403, data: zAppNotFoundError },
app_suggested_questions_after_answer_disabled: {
status: 403,
data: zAppSuggestedQuestionsAfterAnswerDisabledError,
},
app_unavailable: { status: 400, data: zAppUnavailableError },
completion_request_error: { status: 400, data: zCompletionRequestError },
model_currently_not_support: { status: 400, data: zModelCurrentlyNotSupportError },
not_chat_app: { status: 400, data: zNotChatAppError },
provider_not_initialize: { status: 400, data: zProviderNotInitializeError },
provider_quota_exceeded: { status: 400, data: zProviderQuotaExceededError },
workspace_archived: { status: 403, data: zWorkspaceArchivedError },
workspace_not_found: { status: 403, data: zWorkspaceNotFoundError },
})
export const suggested = {
get: get61,
+52 -3
View File
@@ -24,6 +24,11 @@ export type AgentThought = {
tool_labels: AnyJsonValue
}
export type AgentVersionNotFoundError = ErrorResponse & {
code: 'agent_version_not_found_error'
status: 404
}
export type Annotation = {
answer?: string | null
created_at?: number | null
@@ -70,6 +75,16 @@ export type AnnotationReplyActionPayload = {
export type AnyJsonValue = unknown
export type AppAbnormalStatusError = ErrorResponse & {
code: 'app_abnormal_status'
status: 403
}
export type AppApiDisabledError = ErrorResponse & {
code: 'app_api_disabled'
status: 403
}
export type AppFeedbackListResponse = {
data: Array<AppFeedbackResponse>
}
@@ -102,6 +117,16 @@ export type AppMetaResponse = {
}
}
export type AppNotFoundError = ErrorResponse & {
code: 'app_not_found'
status: 403
}
export type AppSuggestedQuestionsAfterAnswerDisabledError = ErrorResponse & {
code: 'app_suggested_questions_after_answer_disabled'
status: 403
}
export type AppUnavailableError = ErrorResponse & {
code: 'app_unavailable'
status: 400
@@ -4450,6 +4475,16 @@ export type WorkflowVersionExecutionNotAllowedError = ErrorResponse & {
status: 403
}
export type WorkspaceArchivedError = ErrorResponse & {
code: 'workspace_archived'
status: 403
}
export type WorkspaceNotFoundError = ErrorResponse & {
code: 'workspace_not_found'
status: 403
}
export type HumanInputFormSubmitResponseWritable = {
[key: string]: never
}
@@ -8821,10 +8856,24 @@ export type GetMessagesByMessageIdSuggestedData = {
}
export type GetMessagesByMessageIdSuggestedErrors = {
400: NotChatAppError | ErrorResponse
400:
| AppUnavailableError
| CompletionRequestError
| ModelCurrentlyNotSupportError
| NotChatAppError
| ProviderNotInitializeError
| ProviderQuotaExceededError
| ErrorResponse
401: ErrorResponse
403: ErrorResponse
404: ErrorResponse
403:
| AppAbnormalStatusError
| AppApiDisabledError
| AppNotFoundError
| AppSuggestedQuestionsAfterAnswerDisabledError
| WorkspaceArchivedError
| WorkspaceNotFoundError
| ErrorResponse
404: AgentVersionNotFoundError | ErrorResponse
500: ErrorResponse
default: ErrorResponse
}
+84
View File
@@ -944,6 +944,66 @@ export const zAgentNotPublishedError = zErrorResponse.and(
}),
)
/**
* AgentVersionNotFoundError
*
* Agent config version not found.
*/
export const zAgentVersionNotFoundError = zErrorResponse.and(
z.object({
code: z.literal('agent_version_not_found_error'),
status: z.literal(404),
}),
)
/**
* AppAbnormalStatusError
*
* The app's status is abnormal.
*/
export const zAppAbnormalStatusError = zErrorResponse.and(
z.object({
code: z.literal('app_abnormal_status'),
status: z.literal(403),
}),
)
/**
* AppApiDisabledError
*
* The app's API service has been disabled.
*/
export const zAppApiDisabledError = zErrorResponse.and(
z.object({
code: z.literal('app_api_disabled'),
status: z.literal(403),
}),
)
/**
* AppNotFoundError
*
* The app no longer exists.
*/
export const zAppNotFoundError = zErrorResponse.and(
z.object({
code: z.literal('app_not_found'),
status: z.literal(403),
}),
)
/**
* AppSuggestedQuestionsAfterAnswerDisabledError
*
* The 'Suggested Questions After Answer' feature is disabled.
*/
export const zAppSuggestedQuestionsAfterAnswerDisabledError = zErrorResponse.and(
z.object({
code: z.literal('app_suggested_questions_after_answer_disabled'),
status: z.literal(403),
}),
)
/**
* AppUnavailableError
*
@@ -5866,6 +5926,30 @@ export const zWorkflowVersionExecutionNotAllowedError = zErrorResponse.and(
}),
)
/**
* WorkspaceArchivedError
*
* The workspace's status is archived.
*/
export const zWorkspaceArchivedError = zErrorResponse.and(
z.object({
code: z.literal('workspace_archived'),
status: z.literal(403),
}),
)
/**
* WorkspaceNotFoundError
*
* Tenant does not exist.
*/
export const zWorkspaceNotFoundError = zErrorResponse.and(
z.object({
code: z.literal('workspace_not_found'),
status: z.literal(403),
}),
)
/**
* HumanInputFormSubmitResponse
*/
+10
View File
@@ -26,6 +26,14 @@ const zAgentNotPublishedError = /* @__PURE__ */ (() =>
}),
))()
const zAgentVersionNotFoundError = /* @__PURE__ */ (() =>
zErrorResponse.and(
z.object({
code: z.literal('agent_version_not_found_error'),
status: z.literal(404),
}),
))()
const zAppMoreLikeThisDisabledError = /* @__PURE__ */ (() =>
zErrorResponse.and(
z.object({
@@ -526,10 +534,12 @@ export const get4Errors = {
}
export const get5Errors = {
agent_version_not_found_error: { status: 404, data: zAgentVersionNotFoundError },
app_suggested_questions_after_answer_disabled: {
status: 403,
data: zAppSuggestedQuestionsAfterAnswerDisabledError,
},
app_unavailable: { status: 400, data: zAppUnavailableError },
completion_request_error: { status: 400, data: zCompletionRequestError },
model_currently_not_support: { status: 400, data: zModelCurrentlyNotSupportError },
not_chat_app: { status: 400, data: zNotChatAppError },
+6 -2
View File
@@ -5,6 +5,7 @@ import * as z from 'zod'
import {
zAccountBannedError,
zAgentNotPublishedError,
zAgentVersionNotFoundError,
zAppMoreLikeThisDisabledError,
zAppSuggestedQuestionsAfterAnswerDisabledError,
zAppUnavailableError,
@@ -841,11 +842,12 @@ export const moreLikeThis = {
}
/**
* Get suggested follow-up questions after a message (chat apps only).
* Get suggested follow-up questions after a message (chat apps only). If no usable model can be resolved or the model call to generate questions fails, the response is HTTP 200 with an empty data list. Model invocation failures during history token counting instead return HTTP 400 with `completion_request_error`.
*/
export const get5 = oc
.route({
description: 'Get suggested follow-up questions after a message (chat apps only).',
description:
'Get suggested follow-up questions after a message (chat apps only). If no usable model can be resolved or the model call to generate questions fails, the response is HTTP 200 with an empty data list. Model invocation failures during history token counting instead return HTTP 400 with `completion_request_error`.',
inputStructure: 'detailed',
method: 'GET',
operationId: 'getMessagesByMessageIdSuggestedQuestions',
@@ -855,10 +857,12 @@ export const get5 = oc
.input(z.object({ params: zGetMessagesByMessageIdSuggestedQuestionsPath }))
.output(zGetMessagesByMessageIdSuggestedQuestionsResponse)
.errors({
agent_version_not_found_error: { status: 404, data: zAgentVersionNotFoundError },
app_suggested_questions_after_answer_disabled: {
status: 403,
data: zAppSuggestedQuestionsAfterAnswerDisabledError,
},
app_unavailable: { status: 400, data: zAppUnavailableError },
completion_request_error: { status: 400, data: zCompletionRequestError },
model_currently_not_support: { status: 400, data: zModelCurrentlyNotSupportError },
not_chat_app: { status: 400, data: zNotChatAppError },
+7 -1
View File
@@ -42,6 +42,11 @@ export type AgentThought = {
tool_labels: AnyJsonValue
}
export type AgentVersionNotFoundError = ErrorResponse & {
code: 'agent_version_not_found_error'
status: 404
}
export type AnyJsonValue = unknown
export type AppAccessModeQuery = {
@@ -2032,6 +2037,7 @@ export type GetMessagesByMessageIdSuggestedQuestionsData = {
export type GetMessagesByMessageIdSuggestedQuestionsErrors = {
400:
| AppUnavailableError
| CompletionRequestError
| ModelCurrentlyNotSupportError
| NotChatAppError
@@ -2040,7 +2046,7 @@ export type GetMessagesByMessageIdSuggestedQuestionsErrors = {
| ErrorResponse
401: ErrorResponse
403: AppSuggestedQuestionsAfterAnswerDisabledError | ErrorResponse
404: ErrorResponse
404: AgentVersionNotFoundError | ErrorResponse
500: ErrorResponse
default: ErrorResponse
}
+12
View File
@@ -230,6 +230,18 @@ export const zAgentNotPublishedError = zErrorResponse.and(
}),
)
/**
* AgentVersionNotFoundError
*
* Agent config version not found.
*/
export const zAgentVersionNotFoundError = zErrorResponse.and(
z.object({
code: z.literal('agent_version_not_found_error'),
status: z.literal(404),
}),
)
/**
* AppMoreLikeThisDisabledError
*