diff --git a/backend/apps/agent_app.py b/backend/apps/agent_app.py index 6aad1ce9e..c5947a23f 100644 --- a/backend/apps/agent_app.py +++ b/backend/apps/agent_app.py @@ -127,6 +127,8 @@ async def agent_run_api( ) except ForbiddenError as e: raise HTTPException(status_code=HTTPStatus.FORBIDDEN, detail=str(e)) from e + except AppException: + raise except ValidationError as e: raise HTTPException( status_code=HTTPStatus.UNPROCESSABLE_ENTITY, diff --git a/backend/apps/conversation_management_app.py b/backend/apps/conversation_management_app.py index 0688788d1..69d8d691e 100644 --- a/backend/apps/conversation_management_app.py +++ b/backend/apps/conversation_management_app.py @@ -13,7 +13,11 @@ OpinionRequest, RenameRequest, ) -from consts.exceptions import ConversationNotFoundError, ValidationError +from consts.exceptions import ( + AppException, + ConversationNotFoundError, + ValidationError, +) from services.conversation_management_service import ( create_new_conversation, delete_conversation_service, @@ -51,6 +55,8 @@ async def create_new_conversation_endpoint(request: ConversationRequest, authori user_id, tenant_id = get_current_user_id(authorization) conversation_data = create_new_conversation(request.title, user_id) return ConversationResponse(code=0, message="success", data=conversation_data) + except AppException: + raise except Exception as e: logging.error(f"Failed to create conversation: {str(e)}") raise HTTPException(status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail=str(e)) diff --git a/backend/consts/const.py b/backend/consts/const.py index 577dfabbb..5b7723389 100644 --- a/backend/consts/const.py +++ b/backend/consts/const.py @@ -183,6 +183,8 @@ class VectorDatabaseType(str, Enum): MAX_GROUPS_PER_TENANT = 1_000 MAX_SUPER_ADMIN_COUNT = 1 MAX_ADMINS_PER_TENANT = 1_000 +MAX_CONVERSATION_TURNS = 100 +MAX_CONVERSATIONS_PER_USER = 1_000 # Invitation code type for asset administrator registration ASSET_OWNER_INVITE_CODE_TYPE = "ASSET_OWNER_INVITE" diff --git a/backend/consts/error_code.py b/backend/consts/error_code.py index 24fd9d2f5..a99053c8c 100644 --- a/backend/consts/error_code.py +++ b/backend/consts/error_code.py @@ -278,6 +278,8 @@ class ErrorCode(Enum): ErrorCode.COMMON_MISSING_REQUIRED_FIELD: 400, # Common - Rate Limit ErrorCode.COMMON_RATE_LIMIT_EXCEEDED: 429, + # Tenant resources + ErrorCode.TENANT_RESOURCE_EXCEEDED: 429, # Common - Resource ErrorCode.COMMON_RESOURCE_NOT_FOUND: 404, ErrorCode.COMMON_RESOURCE_ALREADY_EXISTS: 409, diff --git a/backend/database/conversation_db.py b/backend/database/conversation_db.py index 0a8b91c20..08c56c4a6 100644 --- a/backend/database/conversation_db.py +++ b/backend/database/conversation_db.py @@ -2,8 +2,15 @@ from datetime import datetime from typing import Any, Dict, List, Optional, TypedDict -from sqlalchemy import asc, desc, func, insert, select, update +from sqlalchemy import asc, desc, func, insert, select, text, update +from consts.const import ( + MAX_CONVERSATION_TURNS, + MAX_CONVERSATIONS_PER_USER, + MESSAGE_ROLE, +) +from consts.error_code import ErrorCode +from consts.exceptions import AppException from .client import as_dict, db_client, get_db_session from .db_models import ( ConversationMessage, @@ -102,6 +109,69 @@ def _get_effective_tenant_id(user_tenant: Dict[str, Any]) -> str: return DEFAULT_TENANT_ID +def count_user_turns(conversation_id: int) -> int: + """Count active user messages, treating each one as one conversation turn.""" + with get_db_session() as session: + statement = select(func.count(ConversationMessage.message_id)).where( + ConversationMessage.conversation_id == int(conversation_id), + ConversationMessage.message_role == MESSAGE_ROLE["USER"], + ConversationMessage.delete_flag == "N", + ) + return int(session.execute(statement).scalar_one()) + + +def _lock_limit_scope(session, scope: str) -> None: + """Serialize quota checks for one user or conversation in PostgreSQL.""" + session.execute( + text("SELECT pg_advisory_xact_lock(hashtext(:lock_key))"), + {"lock_key": scope}, + ) + + +def _enforce_user_conversation_limit(session, user_id: Optional[str]) -> None: + if not user_id: + return + + _lock_limit_scope(session, f"conversation-user-limit:{user_id}") + statement = select(func.count(ConversationRecord.conversation_id)).where( + ConversationRecord.created_by == user_id, + ConversationRecord.delete_flag == "N", + ) + conversation_count = int(session.execute(statement).scalar_one()) + if conversation_count >= MAX_CONVERSATIONS_PER_USER: + raise AppException( + ErrorCode.TENANT_RESOURCE_EXCEEDED, + "Conversation history limit reached: " + f"maximum {MAX_CONVERSATIONS_PER_USER} conversations per user", + details={"resource": "conversations", "limit": MAX_CONVERSATIONS_PER_USER}, + ) + + +def _enforce_conversation_turn_limit( + session, + conversation_id: int, + message_role: str, + user_id: Optional[str], +) -> None: + if not user_id or message_role != MESSAGE_ROLE["USER"]: + return + + _lock_limit_scope(session, f"conversation-turn-limit:{conversation_id}") + statement = select(func.count(ConversationMessage.message_id)).where( + ConversationMessage.conversation_id == conversation_id, + ConversationMessage.message_role == MESSAGE_ROLE["USER"], + ConversationMessage.delete_flag == "N", + ) + turn_count = int(session.execute(statement).scalar_one()) + if turn_count >= MAX_CONVERSATION_TURNS: + raise AppException( + ErrorCode.TENANT_RESOURCE_EXCEEDED, + "Conversation turn limit reached: " + f"maximum {MAX_CONVERSATION_TURNS} turns per conversation", + details={"resource": "conversation_turns", "limit": MAX_CONVERSATION_TURNS}, + ) + + def create_conversation(conversation_title: str, user_id: Optional[str] = None, agent_id: Optional[int] = None, chat_mode: Optional[str] = None, @@ -120,6 +190,8 @@ def create_conversation(conversation_title: str, user_id: Optional[str] = None, Dict[str, Any]: Dictionary containing complete information of the newly created conversation """ with get_db_session() as session: + _enforce_user_conversation_limit(session, user_id) + # Prepare data dictionary data = {"conversation_title": conversation_title, "delete_flag": 'N'} if agent_id is not None: @@ -180,6 +252,13 @@ def create_conversation_message(message_data: Dict[str, Any], user_id: Optional[ # Ensure conversation_id is integer type conversation_id = int(message_data['conversation_id']) message_idx = int(message_data['message_idx']) + message_role = message_data['role'] + _enforce_conversation_turn_limit( + session=session, + conversation_id=conversation_id, + message_role=message_role, + user_id=user_id, + ) minio_files = message_data.get('minio_files') # Convert minio_files to JSON string for storage @@ -189,7 +268,7 @@ def create_conversation_message(message_data: Dict[str, Any], user_id: Optional[ minio_files = json.dumps(minio_files) # Prepare data dictionary - data = {"conversation_id": conversation_id, "message_index": message_idx, "message_role": message_data['role'], + data = {"conversation_id": conversation_id, "message_index": message_idx, "message_role": message_role, "message_content": message_data['content'], "minio_files": minio_files, "opinion_flag": None, "delete_flag": 'N', "status": status} if user_id: diff --git a/backend/services/agent_service.py b/backend/services/agent_service.py index 41e2e446e..bedf27c38 100644 --- a/backend/services/agent_service.py +++ b/backend/services/agent_service.py @@ -94,6 +94,7 @@ from utils.str_utils import convert_list_to_string, convert_string_to_list from services.conversation_management_service import ( create_new_conversation, + ensure_conversation_turn_capacity, generate_conversation_title_service, # noqa: F401 - compatibility patch point get_conversation_service, get_current_run_user_message_id, @@ -3320,6 +3321,18 @@ async def run_agent_stream( resolved_user_id, ) + if ( + not agent_request.is_debug + and not resume + and not skip_user_save + and not is_new_conversation + and agent_request.conversation_id is not None + ): + ensure_conversation_turn_capacity( + conversation_id=agent_request.conversation_id, + user_id=resolved_user_id, + ) + if ( not agent_request.is_debug and not is_new_conversation diff --git a/backend/services/conversation_management_service.py b/backend/services/conversation_management_service.py index 1e6d2da56..37a44633b 100644 --- a/backend/services/conversation_management_service.py +++ b/backend/services/conversation_management_service.py @@ -6,11 +6,20 @@ from jinja2 import StrictUndefined, Template -from consts.const import LANGUAGE, MODEL_CONFIG_MAPPING, MESSAGE_ROLE, DEFAULT_EN_TITLE, DEFAULT_ZH_TITLE +from consts.const import ( + DEFAULT_EN_TITLE, + DEFAULT_ZH_TITLE, + LANGUAGE, + MAX_CONVERSATION_TURNS, + MESSAGE_ROLE, + MODEL_CONFIG_MAPPING, +) +from consts.error_code import ErrorCode from consts.model import AgentRequest, MessageRequest, MessageUnit -from consts.exceptions import ConversationNotFoundError, ValidationError +from consts.exceptions import AppException, ConversationNotFoundError, ValidationError from database.conversation_db import ( CHAT_MODE_VALUES, + count_user_turns, create_conversation, create_conversation_message, create_message_unit, @@ -106,6 +115,21 @@ def save_message(request: MessageRequest, user_id: str, tenant_id: str, return create_conversation_message(message_data_copy, user_id, status=status) +def ensure_conversation_turn_capacity(conversation_id: int, user_id: str) -> None: + """Reject a new turn before streaming starts when the conversation is full.""" + if not user_id: + return + + turn_count = count_user_turns(conversation_id) + if turn_count >= MAX_CONVERSATION_TURNS: + raise AppException( + ErrorCode.TENANT_RESOURCE_EXCEEDED, + "Conversation turn limit reached: " + f"maximum {MAX_CONVERSATION_TURNS} turns per conversation", + details={"resource": "conversation_turns", "limit": MAX_CONVERSATION_TURNS}, + ) + + def save_message_unit(message_id: int, conversation_id: int, unit_index: int, unit_type: str, unit_content: Any, user_id: Optional[str] = None, @@ -378,6 +402,8 @@ def create_new_conversation( create_kwargs["knowledge_scope"] = knowledge_scope conversation_data = create_conversation(title, user_id, **create_kwargs) return conversation_data + except AppException: + raise except Exception as e: logging.error(f"Failed to create conversation: {str(e)}") raise Exception(str(e)) diff --git a/frontend/app/[locale]/chat/internal/chatInterface.tsx b/frontend/app/[locale]/chat/internal/chatInterface.tsx index 544e4e9db..203607c54 100644 --- a/frontend/app/[locale]/chat/internal/chatInterface.tsx +++ b/frontend/app/[locale]/chat/internal/chatInterface.tsx @@ -9,6 +9,7 @@ import { useTranslation } from "react-i18next"; import { ROLE_ASSISTANT } from "@/const/agentConfig"; import { MESSAGE_ROLES } from "@/const/chatConfig"; +import { getConversationResourceLimitMessage } from "@/const/errorMessageI18n"; import { useConfig } from "@/hooks/useConfig"; import { useModelList } from "@/hooks/model/useModelList"; import { useAuthorizationContext } from "@/components/providers/AuthorizationProvider"; @@ -1034,7 +1035,9 @@ export function ChatInterface() { }); } else { log.error(t("chatInterface.errorLabel"), error); - const errorMessage = t("chatInterface.errorProcessingRequest"); + const errorMessage = + getConversationResourceLimitMessage(error, t) || + t("chatInterface.errorProcessingRequest"); setSessionMessages((prev) => { const newMessages = { ...prev }; const lastMsg = diff --git a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx index 3c4ada572..b699f9d19 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/thread.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/thread.tsx @@ -63,6 +63,7 @@ import { message } from "antd"; import type { Agent, PublishedAgent } from "@/types/agentConfig"; import { getAgentIcon } from "@/lib/chat/agentIconUtils"; import type { ModelOption } from "../ui/model-selector"; +import { getConversationResourceLimitMessage } from "@/const/errorMessageI18n"; import AutomationProposalMessage from "@/features/agentAutomation/components/AutomationProposalMessage"; import type { AgentAutomationProposalData } from "@/types/agentAutomation"; import { @@ -964,10 +965,21 @@ const ThreadScrollToBottom: FC = () => { }; const MessageError: FC = () => { + const { t } = useTranslation("common"); + const rawError = useAuiState((state) => { + const status = state.message.status; + return status?.type === "incomplete" && status.reason === "error" + ? status.error + : undefined; + }); + const localizedMessage = getConversationResourceLimitMessage(rawError, t); + return ( - + + {localizedMessage ?? undefined} + ); diff --git a/frontend/const/errorMessageI18n.ts b/frontend/const/errorMessageI18n.ts index 1c21257f7..dfb06f5a9 100644 --- a/frontend/const/errorMessageI18n.ts +++ b/frontend/const/errorMessageI18n.ts @@ -43,6 +43,112 @@ export const getI18nErrorMessage = ( ); }; +const CONVERSATION_RESOURCE_LIMIT_TRANSLATION_KEYS: Record = { + conversations: "chatInterface.conversationLimitExceeded", + conversation_turns: "chatInterface.turnLimitExceeded", +}; + +const CONVERSATION_RESOURCE_LIMIT_PATTERNS: Array<{ + resource: keyof typeof CONVERSATION_RESOURCE_LIMIT_TRANSLATION_KEYS; + pattern: RegExp; +}> = [ + { + resource: "conversations", + pattern: + /Conversation history limit reached:\s*maximum\s+(\d+)\s+conversations?\s+per user/i, + }, + { + resource: "conversation_turns", + pattern: + /Conversation turn limit reached:\s*maximum\s+(\d+)\s+turns?\s+per conversation/i, + }, +]; + +type ConversationResourceLimitData = { + resource?: unknown; + limit?: unknown; +}; + +function getConversationErrorFields(error: unknown): { + code?: unknown; + message?: string; + data?: ConversationResourceLimitData; +} { + if (typeof error === "string") { + return { message: error }; + } + + if (!error || typeof error !== "object") { + return {}; + } + + const candidate = error as { + code?: unknown; + message?: unknown; + data?: unknown; + details?: unknown; + }; + const data = + candidate.data && typeof candidate.data === "object" + ? (candidate.data as ConversationResourceLimitData) + : candidate.details && typeof candidate.details === "object" + ? (candidate.details as ConversationResourceLimitData) + : undefined; + + return { + code: candidate.code, + message: + typeof candidate.message === "string" ? candidate.message : undefined, + data, + }; +} + +/** + * Translate conversation resource-limit errors for both chat implementations. + * + * The legacy chat receives the original ApiError, while assistant-ui may + * normalize it to an Error-like object and retain only the backend message. + * Handle both shapes so the user-facing language does not depend on the chat + * implementation that initiated the request. + */ +export const getConversationResourceLimitMessage = ( + error: unknown, + t: (key: string, options?: Record) => string +): string | null => { + const { + code, + message: errorMessage, + data, + } = getConversationErrorFields(error); + if (String(code) === ErrorCode.TENANT_RESOURCE_EXCEEDED) { + const resource = typeof data?.resource === "string" ? data.resource : ""; + const limit = data?.limit; + const translationKey = + CONVERSATION_RESOURCE_LIMIT_TRANSLATION_KEYS[resource]; + if ( + translationKey && + (typeof limit === "number" || typeof limit === "string") + ) { + return t(translationKey, { limit }); + } + } + + if (!errorMessage) { + return null; + } + + for (const { resource, pattern } of CONVERSATION_RESOURCE_LIMIT_PATTERNS) { + const match = errorMessage.match(pattern); + if (match) { + return t(CONVERSATION_RESOURCE_LIMIT_TRANSLATION_KEYS[resource], { + limit: match[1], + }); + } + } + + return null; +}; + /** * Hook to get error message with i18n support. * diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index fba37a444..8274eb34c 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -59,6 +59,8 @@ "chatInterface.fileProcessingStopped": "File parsing stopped", "chatInterface.conversationStopped": "Conversation stopped", "chatInterface.errorProcessingRequest": "An error occurred while processing the request", + "chatInterface.conversationLimitExceeded": "Conversation history has reached the limit of {{limit}} per user", + "chatInterface.turnLimitExceeded": "This conversation has reached the limit of {{limit}} turns", "chatInterface.errorFetchingConversationList": "Failed to fetch conversation list during initialization:", "chatInterface.errorFetchingConversationDetailsError": "Error fetching conversation details:", "chatInterface.errorFetchingAttachmentUrl": "Failed to fetch attachment URL: {{object_name}}", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index 9f964a542..bf7e58b4c 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -59,6 +59,8 @@ "chatInterface.fileProcessingStopped": "文件解析已停止", "chatInterface.conversationStopped": "对话已停止", "chatInterface.errorProcessingRequest": "处理请求时发生错误", + "chatInterface.conversationLimitExceeded": "历史会话数量已达到上限:每位用户最多 {{limit}} 个", + "chatInterface.turnLimitExceeded": "当前会话对话轮数已达到上限:每个会话最多 {{limit}} 轮", "chatInterface.errorFetchingConversationList": "初始化时获取对话列表失败:", "chatInterface.errorFetchingConversationDetailsError": "获取对话详情错误:", "chatInterface.errorFetchingAttachmentUrl": "获取附件URL失败: {{object_name}}", diff --git a/frontend/services/api.ts b/frontend/services/api.ts index 5b2af4ea9..7b23cfe2f 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -735,7 +735,8 @@ export const API_ENDPOINTS = { export class ApiError extends Error { constructor( public code: string | number, - message: string + message: string, + public data?: Record ) { super(message); this.name = "ApiError"; @@ -755,23 +756,40 @@ export const fetchWithErrorHandling = async ( // Try to parse JSON response for business error code first let errorCode = response.status; let errorMessage = `Request failed: ${response.status}`; + let errorData: Record | undefined; const errorText = await response.text(); try { - const errorData = JSON.parse(errorText); - const errorDetail = - errorData?.detail && typeof errorData.detail === "object" - ? errorData.detail - : errorData?.message && typeof errorData.message === "object" - ? errorData.message - : errorData; + const parsedError = JSON.parse(errorText); + let errorDetail = parsedError; + if (parsedError?.detail && typeof parsedError.detail === "object") { + errorDetail = parsedError.detail; + } else if ( + parsedError?.message && + typeof parsedError.message === "object" + ) { + errorDetail = parsedError.message; + } if (errorDetail?.code) { errorCode = errorDetail.code; errorMessage = errorDetail.message || errorMessage; - } else if (typeof errorData?.detail === "string") { - errorMessage = errorData.detail; - } else if (typeof errorData?.message === "string") { - errorMessage = errorData.message; + if ( + errorDetail.data && + typeof errorDetail.data === "object" && + !Array.isArray(errorDetail.data) + ) { + errorData = errorDetail.data as Record; + } else if ( + errorDetail.details && + typeof errorDetail.details === "object" && + !Array.isArray(errorDetail.details) + ) { + errorData = errorDetail.details as Record; + } + } else if (typeof parsedError?.detail === "string") { + errorMessage = parsedError.detail; + } else if (typeof parsedError?.message === "string") { + errorMessage = parsedError.message; } else { errorMessage = errorText || errorMessage; } @@ -788,13 +806,13 @@ export const fetchWithErrorHandling = async ( errorCodeStr === ErrorCode.TOKEN_INVALID ) { handleSessionExpired(); - throw new ApiError(errorCode, errorMessage); + throw new ApiError(errorCode, errorMessage, errorData); } // Handle HTTP 401 - trigger session expired modal for all unauthorized errors if (response.status === 401) { handleSessionExpired(); - throw new ApiError(errorCode, errorMessage); + throw new ApiError(errorCode, errorMessage, errorData); } // Handle custom 499 error code (client closed connection) @@ -828,7 +846,7 @@ export const fetchWithErrorHandling = async ( ); } - throw new ApiError(errorCode, errorMessage); + throw new ApiError(errorCode, errorMessage, errorData); } return response; diff --git a/test/backend/app/test_agent_app.py b/test/backend/app/test_agent_app.py index 4ca23f962..232f24066 100644 --- a/test/backend/app/test_agent_app.py +++ b/test/backend/app/test_agent_app.py @@ -435,6 +435,38 @@ async def test_agent_run_api_maps_scope_validation_to_422(mocker): assert exc_info.value.detail == "Selected knowledge bases are incompatible" +@pytest.mark.asyncio +async def test_agent_run_api_preserves_conversation_resource_limit(mocker): + from consts.error_code import ErrorCode + from consts.exceptions import AppException + from consts.model import AgentRequest + from starlette.requests import Request + + from apps.agent_app import agent_run_api + + mocker.patch( + "apps.agent_app.run_agent_stream", + new_callable=AsyncMock, + side_effect=AppException( + ErrorCode.TENANT_RESOURCE_EXCEEDED, + "Conversation turn limit reached", + details={"resource": "conversation_turns", "limit": 100}, + ), + ) + request = AgentRequest(agent_id=1, conversation_id=123, query="test query", history=[]) + + with pytest.raises(AppException) as exc_info: + await agent_run_api( + agent_request=request, + http_request=Request({"type": "http", "headers": []}), + authorization="Bearer token", + resume=False, + ) + + assert exc_info.value.http_status == 429 + assert exc_info.value.details == {"resource": "conversation_turns", "limit": 100} + + def test_agent_stop_api_success(mocker, mock_conversation_id): """Test agent_stop_api success case.""" mock_get_user_id = mocker.patch("apps.agent_app.get_current_user_id") diff --git a/test/backend/app/test_conversation_management_app.py b/test/backend/app/test_conversation_management_app.py index 71e7867fb..bc11ca401 100644 --- a/test/backend/app/test_conversation_management_app.py +++ b/test/backend/app/test_conversation_management_app.py @@ -49,7 +49,8 @@ get_message_id_endpoint, update_conversation_knowledge_scope_endpoint, ) -from consts.exceptions import ValidationError +from consts.error_code import ErrorCode +from consts.exceptions import AppException, ValidationError # ----------------------------- @@ -151,6 +152,25 @@ async def test_create_new_conversation_failure(conversation_mocks): conversation_mocks['logging'].error.assert_called_once() +@pytest.mark.asyncio +async def test_create_new_conversation_resource_limit_preserves_structured_error(conversation_mocks): + conversation_mocks['get_current_user_id'].return_value = ("user_id", "tenant_id") + conversation_mocks['create_new_convo'].side_effect = AppException( + ErrorCode.TENANT_RESOURCE_EXCEEDED, + "Conversation history limit reached", + details={"resource": "conversations", "limit": 1_000}, + ) + + request_obj = MagicMock() + request_obj.title = "New Conversation" + + with pytest.raises(AppException) as exc_info: + await create_new_conversation_endpoint(request_obj, authorization="Bearer token") + + assert exc_info.value.http_status == 429 + assert exc_info.value.details == {"resource": "conversations", "limit": 1_000} + + @pytest.mark.asyncio async def test_list_conversations_success(conversation_mocks): """Verify successful retrieval of conversation list""" diff --git a/test/backend/database/test_conversation_db.py b/test/backend/database/test_conversation_db.py index c36f1c900..13278eb73 100644 --- a/test/backend/database/test_conversation_db.py +++ b/test/backend/database/test_conversation_db.py @@ -27,6 +27,7 @@ def _reset_captured(): sa_mod.desc = MagicMock(name="desc") sa_mod.func = MagicMock(name="func") sa_mod.select = MagicMock(name="select") +sa_mod.text = MagicMock(name="text") def _create_insert_mock(): @@ -226,6 +227,9 @@ def _add_update_tracking(data, user_id): update_message_unit_status, update_conversation_knowledge_scope, ) +import backend.database.conversation_db as conversation_db +from consts.error_code import ErrorCode +from consts.exceptions import AppException @pytest.fixture(autouse=True) @@ -506,7 +510,7 @@ def test_create_conversation_success(monkeypatch, mock_session_ctx): assert result["agent_id"] == 7 assert result["create_time"] == 1234567890 assert result["update_time"] == 1234567890 - session.execute.assert_called_once() + assert session.execute.call_count == 3 assert _captured_insert_values["agent_id"] == 7 @@ -529,6 +533,54 @@ def test_create_conversation_without_user_id(monkeypatch, mock_session_ctx): session.execute.assert_called_once() +def test_user_conversation_limit_allows_value_below_limit(): + session = MagicMock() + count_result = MagicMock() + count_result.scalar_one.return_value = 999 + session.execute.side_effect = [MagicMock(), count_result] + + conversation_db._enforce_user_conversation_limit(session, "user-1") + + +def test_user_conversation_limit_rejects_value_at_limit(): + session = MagicMock() + count_result = MagicMock() + count_result.scalar_one.return_value = 1_000 + session.execute.side_effect = [MagicMock(), count_result] + + with pytest.raises(AppException) as exc_info: + conversation_db._enforce_user_conversation_limit(session, "user-1") + + assert exc_info.value.error_code == ErrorCode.TENANT_RESOURCE_EXCEEDED + assert exc_info.value.details == {"resource": "conversations", "limit": 1_000} + + +def test_conversation_turn_limit_allows_value_below_limit(): + session = MagicMock() + count_result = MagicMock() + count_result.scalar_one.return_value = 99 + session.execute.side_effect = [MagicMock(), count_result] + + conversation_db._enforce_conversation_turn_limit( + session, conversation_id=1, message_role="user", user_id="user-1" + ) + + +def test_conversation_turn_limit_rejects_value_at_limit(): + session = MagicMock() + count_result = MagicMock() + count_result.scalar_one.return_value = 100 + session.execute.side_effect = [MagicMock(), count_result] + + with pytest.raises(AppException) as exc_info: + conversation_db._enforce_conversation_turn_limit( + session, conversation_id=1, message_role="user", user_id="user-1" + ) + + assert exc_info.value.error_code == ErrorCode.TENANT_RESOURCE_EXCEEDED + assert exc_info.value.details == {"resource": "conversation_turns", "limit": 100} + + # ============================================================================= # Tests for create_conversation_message # ============================================================================= diff --git a/test/backend/services/test_conversation_management_service.py b/test/backend/services/test_conversation_management_service.py index dcddaf940..c0027d054 100644 --- a/test/backend/services/test_conversation_management_service.py +++ b/test/backend/services/test_conversation_management_service.py @@ -206,6 +206,7 @@ def validate(self): pass call_llm_for_title, update_conversation_title, create_new_conversation, + ensure_conversation_turn_capacity, get_conversation_service, get_conversation_list_service, rename_conversation_service, @@ -218,6 +219,8 @@ def validate(self): pass update_message_opinion_service, get_message_id_by_index_impl ) +from consts.error_code import ErrorCode +from consts.exceptions import AppException class TestConversationManagementService(unittest.TestCase): @@ -521,6 +524,30 @@ def test_create_new_conversation(self, mock_create_conversation): mock_create_conversation.assert_called_once_with( "New Chat", self.user_id, agent_id=None, chat_mode=None) + @patch('backend.services.conversation_management_service.count_user_turns', return_value=99) + def test_ensure_conversation_turn_capacity_allows_below_limit(self, mock_count_user_turns): + ensure_conversation_turn_capacity(123, self.user_id) + + mock_count_user_turns.assert_called_once_with(123) + + @patch('backend.services.conversation_management_service.count_user_turns') + def test_ensure_conversation_turn_capacity_skips_missing_user(self, mock_count_user_turns): + ensure_conversation_turn_capacity(123, "") + + mock_count_user_turns.assert_not_called() + + @patch('backend.services.conversation_management_service.count_user_turns', return_value=100) + def test_ensure_conversation_turn_capacity_rejects_at_limit(self, mock_count_user_turns): + with self.assertRaises(AppException) as exc_info: + ensure_conversation_turn_capacity(123, self.user_id) + + self.assertEqual(exc_info.exception.error_code, ErrorCode.TENANT_RESOURCE_EXCEEDED) + self.assertEqual( + exc_info.exception.details, + {"resource": "conversation_turns", "limit": 100}, + ) + mock_count_user_turns.assert_called_once_with(123) + @patch('backend.services.conversation_management_service.create_conversation') def test_create_new_conversation_with_agent_id(self, mock_create_conversation): # Setup