Compare commits
36
Commits
deploy/saas
...
1.16.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f8ed69ee1 | ||
|
|
59b879d2df | ||
|
|
a57b0b9b58 | ||
|
|
d8506efed6 | ||
|
|
b25b28cc76 | ||
|
|
f68cabe226 | ||
|
|
f73f83c5d6 | ||
|
|
29801d6a65 | ||
|
|
75016e8bfe | ||
|
|
b6dea7b2ba | ||
|
|
b2b1cd7e97 | ||
|
|
ca63e01d45 | ||
|
|
271b6e1f5c | ||
|
|
609ddf9e01 | ||
|
|
9fd1fea58c | ||
|
|
8de3b4d033 | ||
|
|
d9c038daf2 | ||
|
|
c9c057fd04 | ||
|
|
85189be53d | ||
|
|
01e736aaf7 | ||
|
|
a28969f564 | ||
|
|
42f9610ba5 | ||
|
|
1a7ffbe5ee | ||
|
|
481354ca70 | ||
|
|
3c80857ea3 | ||
|
|
94702efbc2 | ||
|
|
755f7b0e8b | ||
|
|
a1c9564b30 | ||
|
|
97acd9c70b | ||
|
|
71601bf76c | ||
|
|
460efbf285 | ||
|
|
a752f43b8e | ||
|
|
b3774bfe1c | ||
|
|
4313d23889 | ||
|
|
26a43db6a4 | ||
|
|
e5d40336b3 |
@@ -666,6 +666,7 @@ PLUGIN_REMOTE_INSTALL_PORT=5003
|
||||
PLUGIN_REMOTE_INSTALL_HOST=localhost
|
||||
PLUGIN_MAX_PACKAGE_SIZE=15728640
|
||||
PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED=true
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_TTL=86400
|
||||
# Comma-separated marketplace plugin IDs whose latest versions are installed for newly registered users.
|
||||
# Example: langgenius/openai,langgenius/gemini
|
||||
@@ -677,6 +678,8 @@ INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y
|
||||
|
||||
# Dify Agent backend
|
||||
AGENT_BACKEND_BASE_URL=http://localhost:5050
|
||||
# Bearer token sent to the Agent backend /runs API. Must match DIFY_AGENT_API_TOKEN on the server side.
|
||||
AGENT_BACKEND_API_TOKEN=dify-agent-run-token-for-dev-only
|
||||
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS=30
|
||||
AGENT_BACKEND_STREAM_MAX_RECONNECTS=3
|
||||
AGENT_BACKEND_RUN_TIMEOUT_SECONDS=1200
|
||||
|
||||
+59
-30
@@ -1,10 +1,13 @@
|
||||
import logging
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from typing import NamedTuple
|
||||
|
||||
import socketio
|
||||
from flask import request
|
||||
from opentelemetry.trace import get_current_span
|
||||
from opentelemetry.trace.span import INVALID_SPAN_ID, INVALID_TRACE_ID
|
||||
from werkzeug.exceptions import Forbidden, HTTPException, ServiceUnavailable
|
||||
|
||||
from configs import dify_config
|
||||
from contexts.wrapper import RecyclableContextVar
|
||||
@@ -42,6 +45,53 @@ _CONSOLE_EXEMPT_PREFIXES = (
|
||||
"/console/api/activate/check",
|
||||
)
|
||||
|
||||
_WEBAPP_EXEMPT_PREFIXES = ("/api/system-features",)
|
||||
|
||||
_INVALID_LICENSE_STATUSES = (LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST)
|
||||
|
||||
|
||||
def _session_surface_error(license_status: LicenseStatus | None) -> HTTPException:
|
||||
if license_status is None:
|
||||
return UnauthorizedAndForceLogout("Unable to verify enterprise license. Please contact your administrator.")
|
||||
return UnauthorizedAndForceLogout(f"Enterprise license is {license_status}. Please contact your administrator.")
|
||||
|
||||
|
||||
def _bearer_surface_error(license_status: LicenseStatus | None) -> HTTPException:
|
||||
"""Token-authed: forcing a logout is meaningless and license state must not leak."""
|
||||
return Forbidden(description="license_required")
|
||||
|
||||
|
||||
def _retryable_surface_error(license_status: LicenseStatus | None) -> HTTPException:
|
||||
"""Webhook senders retry on 5xx but treat 4xx as permanent, disabling the subscription."""
|
||||
return ServiceUnavailable(description="license_required")
|
||||
|
||||
|
||||
class _LicenseGatedSurface(NamedTuple):
|
||||
prefix: str
|
||||
exempt_prefixes: tuple[str, ...]
|
||||
build_error: Callable[[LicenseStatus | None], HTTPException]
|
||||
|
||||
|
||||
# /files (plugin-daemon data plane), /inner/api (enterprise control plane) and /health
|
||||
# stay ungated: blocking them breaks workflow execution or license recovery itself.
|
||||
_LICENSE_GATED_SURFACES = (
|
||||
_LicenseGatedSurface("/console/api/", _CONSOLE_EXEMPT_PREFIXES, _session_surface_error),
|
||||
_LicenseGatedSurface("/api/", _WEBAPP_EXEMPT_PREFIXES, _session_surface_error),
|
||||
_LicenseGatedSurface("/v1", (), _bearer_surface_error),
|
||||
_LicenseGatedSurface("/mcp", (), _bearer_surface_error),
|
||||
_LicenseGatedSurface("/triggers", (), _retryable_surface_error),
|
||||
)
|
||||
|
||||
|
||||
def _match_license_gated_surface(path: str) -> _LicenseGatedSurface | None:
|
||||
for surface in _LICENSE_GATED_SURFACES:
|
||||
if not path.startswith(surface.prefix):
|
||||
continue
|
||||
if any(path.startswith(exempt) for exempt in surface.exempt_prefixes):
|
||||
return None
|
||||
return surface
|
||||
return None
|
||||
|
||||
|
||||
# ----------------------------
|
||||
# Application Factory Function
|
||||
@@ -62,38 +112,17 @@ def create_flask_app_with_configs() -> DifyApp:
|
||||
init_request_context()
|
||||
RecyclableContextVar.increment_thread_recycles()
|
||||
|
||||
# Enterprise license validation for API endpoints (both console and webapp)
|
||||
# When license expires, block all API access except bootstrap endpoints needed
|
||||
# for the frontend to load the license expiration page without infinite reloads.
|
||||
if dify_config.ENTERPRISE_ENABLED:
|
||||
is_console_api = request.path.startswith("/console/api/")
|
||||
is_webapp_api = request.path.startswith("/api/")
|
||||
surface = _match_license_gated_surface(request.path)
|
||||
if surface is not None:
|
||||
try:
|
||||
license_status = EnterpriseService.get_cached_license_status()
|
||||
except Exception:
|
||||
logger.exception("Failed to check enterprise license status")
|
||||
license_status = None
|
||||
|
||||
if is_console_api or is_webapp_api:
|
||||
if is_console_api:
|
||||
is_exempt = any(request.path.startswith(p) for p in _CONSOLE_EXEMPT_PREFIXES)
|
||||
else: # webapp API
|
||||
is_exempt = request.path.startswith("/api/system-features")
|
||||
|
||||
if not is_exempt:
|
||||
try:
|
||||
# Check license status (cached — see EnterpriseService for TTL details)
|
||||
license_status = EnterpriseService.get_cached_license_status()
|
||||
if license_status in (LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST):
|
||||
raise UnauthorizedAndForceLogout(
|
||||
f"Enterprise license is {license_status}. Please contact your administrator."
|
||||
)
|
||||
if license_status is None:
|
||||
raise UnauthorizedAndForceLogout(
|
||||
"Unable to verify enterprise license. Please contact your administrator."
|
||||
)
|
||||
except UnauthorizedAndForceLogout:
|
||||
raise
|
||||
except Exception:
|
||||
logger.exception("Failed to check enterprise license status")
|
||||
raise UnauthorizedAndForceLogout(
|
||||
"Unable to verify enterprise license. Please contact your administrator."
|
||||
)
|
||||
if license_status is None or license_status in _INVALID_LICENSE_STATUSES:
|
||||
raise surface.build_error(license_status)
|
||||
|
||||
# add after request hook for injecting trace headers from OpenTelemetry span context
|
||||
# Only adds headers when OTEL is enabled and has valid context
|
||||
|
||||
@@ -11,6 +11,7 @@ from clients.agent_backend.fake_client import FakeAgentBackendRunClient, FakeAge
|
||||
def create_agent_backend_run_client(
|
||||
*,
|
||||
base_url: str | None = None,
|
||||
api_token: str | None = None,
|
||||
use_fake: bool = False,
|
||||
fake_scenario: str | FakeAgentBackendScenario = FakeAgentBackendScenario.SUCCESS,
|
||||
stream_read_timeout_seconds: float = 30,
|
||||
@@ -22,8 +23,11 @@ def create_agent_backend_run_client(
|
||||
return FakeAgentBackendRunClient(scenario=FakeAgentBackendScenario(fake_scenario))
|
||||
if base_url is None:
|
||||
raise ValueError("base_url is required when creating a real Agent backend client")
|
||||
headers: dict[str, str] = {}
|
||||
if api_token:
|
||||
headers["Authorization"] = f"Bearer {api_token}"
|
||||
return DifyAgentBackendRunClient(
|
||||
Client(base_url=base_url, stream_timeout=stream_read_timeout_seconds),
|
||||
Client(base_url=base_url, stream_timeout=stream_read_timeout_seconds, headers=headers),
|
||||
stream_max_reconnects=stream_max_reconnects,
|
||||
stream_timeout_seconds=stream_run_timeout_seconds,
|
||||
)
|
||||
|
||||
@@ -12,6 +12,11 @@ class AgentBackendConfig(BaseSettings):
|
||||
default=None,
|
||||
)
|
||||
|
||||
AGENT_BACKEND_API_TOKEN: str | None = Field(
|
||||
description="Bearer token for authenticating with the Agent backend /runs API.",
|
||||
default=None,
|
||||
)
|
||||
|
||||
AGENT_BACKEND_USE_FAKE: bool = Field(
|
||||
description="Use the deterministic in-process fake Agent backend client.",
|
||||
default=False,
|
||||
|
||||
@@ -266,6 +266,12 @@ class PluginConfig(BaseSettings):
|
||||
default=60 * 60,
|
||||
)
|
||||
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED: bool = Field(
|
||||
description="Whether tenant plugin model providers are cached in Redis. Disable when plugins are installed "
|
||||
"by a system other than this one, which cannot invalidate the cache when a tenant's plugins change.",
|
||||
default=True,
|
||||
)
|
||||
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_TTL: PositiveInt = Field(
|
||||
description="TTL in seconds for caching tenant plugin model providers in Redis",
|
||||
default=60 * 60 * 24,
|
||||
|
||||
@@ -58,6 +58,7 @@ from services.app_service import (
|
||||
AppResponseView,
|
||||
AppService,
|
||||
CreateAppParams,
|
||||
RecentAppMode,
|
||||
StarredAppListParams,
|
||||
)
|
||||
from services.enterprise import rbac_service as enterprise_rbac_service
|
||||
@@ -139,6 +140,10 @@ class AppListBaseQuery(BaseModel):
|
||||
raise ValueError("Invalid UUID format in creator_ids.") from exc
|
||||
|
||||
|
||||
class RecentAppListQuery(BaseModel):
|
||||
limit: int = Field(default=8, ge=1, le=8, description="Number of recently modified apps to return (1-8)")
|
||||
|
||||
|
||||
class AppListQuery(AppListBaseQuery):
|
||||
pass
|
||||
|
||||
@@ -411,6 +416,33 @@ class AppPartial(AppResponseModel):
|
||||
return to_timestamp(value)
|
||||
|
||||
|
||||
class RecentAppResponse(ResponseModel):
|
||||
id: str
|
||||
name: str
|
||||
icon_type: IconType | None = None
|
||||
icon: str | None = None
|
||||
icon_background: str | None = None
|
||||
mode: RecentAppMode
|
||||
author_name: str | None = None
|
||||
updated_at: int
|
||||
permission_keys: list[str] = Field(default_factory=list)
|
||||
maintainer: str | None = None
|
||||
|
||||
@computed_field(return_type=str | None) # type: ignore[prop-decorator]
|
||||
@property
|
||||
def icon_url(self) -> str | None:
|
||||
return build_icon_url(self.icon_type, self.icon)
|
||||
|
||||
@field_validator("updated_at", mode="before")
|
||||
@classmethod
|
||||
def _normalize_timestamp(cls, value: datetime | int) -> int:
|
||||
return to_timestamp(value)
|
||||
|
||||
|
||||
class RecentAppListResponse(ResponseModel):
|
||||
data: list[RecentAppResponse]
|
||||
|
||||
|
||||
class AppDetail(AppResponseModel):
|
||||
id: str
|
||||
name: str
|
||||
@@ -575,6 +607,8 @@ register_schema_models(
|
||||
register_response_schema_models(
|
||||
console_ns,
|
||||
AppPartial,
|
||||
RecentAppResponse,
|
||||
RecentAppListResponse,
|
||||
AppDetailWithSite,
|
||||
AppPagination,
|
||||
)
|
||||
@@ -699,6 +733,49 @@ class AppListApi(Resource):
|
||||
return app_detail.model_dump(mode="json"), 201
|
||||
|
||||
|
||||
@console_ns.route("/apps/recent")
|
||||
class RecentAppListApi(Resource):
|
||||
@console_ns.doc("list_recent_apps")
|
||||
@console_ns.doc(description="Get recently modified apps for the home Continue Work section")
|
||||
@console_ns.doc(params=query_params_from_model(RecentAppListQuery))
|
||||
@console_ns.response(200, "Success", console_ns.models[RecentAppListResponse.__name__])
|
||||
@setup_required
|
||||
@login_required
|
||||
@account_initialization_required
|
||||
@enterprise_license_required
|
||||
@with_session(write=False)
|
||||
@with_current_user_id
|
||||
@with_current_tenant_id
|
||||
def get(self, current_tenant_id: str, current_user_id: str, session: Session):
|
||||
"""Return the lightweight app cards needed by the Explore home page."""
|
||||
args = query_params_from_request(RecentAppListQuery)
|
||||
params = AppListParams(limit=args.limit)
|
||||
|
||||
permissions = enterprise_rbac_service.RBACService.MyPermissions.get(
|
||||
current_tenant_id,
|
||||
current_user_id,
|
||||
session=session,
|
||||
)
|
||||
if dify_config.RBAC_ENABLED:
|
||||
access_filter = resolve_app_access_filter(
|
||||
current_tenant_id,
|
||||
current_user_id,
|
||||
session=session,
|
||||
permissions=permissions,
|
||||
)
|
||||
access_filter.apply_to_params(params)
|
||||
|
||||
recent_apps = AppService().get_recent_apps(current_user_id, current_tenant_id, params, session)
|
||||
permission_keys_map = permissions.app.permission_keys_by_resource_ids([app.id for app in recent_apps])
|
||||
response_items = [
|
||||
RecentAppResponse.model_validate(app, from_attributes=True).model_copy(
|
||||
update={"permission_keys": permission_keys_map.get(app.id, [])}
|
||||
)
|
||||
for app in recent_apps
|
||||
]
|
||||
return dump_response(RecentAppListResponse, {"data": response_items}), 200
|
||||
|
||||
|
||||
@console_ns.route("/apps/starred")
|
||||
class StarredAppListApi(Resource):
|
||||
@console_ns.doc("list_starred_apps")
|
||||
|
||||
@@ -23,7 +23,6 @@ from .knowledge import retrieval as _knowledge_retrieval
|
||||
from .plugin import agent_config as _agent_config
|
||||
from .plugin import agent_drive as _agent_drive
|
||||
from .plugin import plugin as _plugin
|
||||
from .workspace import plugin_model_providers as _plugin_model_providers
|
||||
from .workspace import workspace as _workspace
|
||||
|
||||
api.add_namespace(inner_api_ns)
|
||||
@@ -36,7 +35,6 @@ __all__ = [
|
||||
"_knowledge_retrieval",
|
||||
"_mail",
|
||||
"_plugin",
|
||||
"_plugin_model_providers",
|
||||
"_runtime_credentials",
|
||||
"_workspace",
|
||||
"api",
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
from flask_restx import Resource
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from controllers.common.schema import register_schema_model
|
||||
from controllers.console.wraps import setup_required
|
||||
from controllers.inner_api import inner_api_ns
|
||||
from controllers.inner_api.wraps import enterprise_inner_api_only
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
|
||||
class InvalidatePluginModelProvidersCachePayload(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
|
||||
tenant_ids: list[str] = Field(default_factory=list, description="Workspace ids whose cache should be invalidated")
|
||||
|
||||
|
||||
register_schema_model(inner_api_ns, InvalidatePluginModelProvidersCachePayload)
|
||||
|
||||
|
||||
@inner_api_ns.route("/enterprise/workspace/plugin-model-providers/invalidate")
|
||||
class EnterprisePluginModelProvidersCacheInvalidate(Resource):
|
||||
@setup_required
|
||||
@enterprise_inner_api_only
|
||||
@inner_api_ns.doc(
|
||||
"enterprise_invalidate_plugin_model_providers_cache",
|
||||
responses={
|
||||
200: "Cache invalidated",
|
||||
400: "Invalid request",
|
||||
401: "Unauthorized - invalid API key",
|
||||
},
|
||||
)
|
||||
@inner_api_ns.expect(inner_api_ns.models[InvalidatePluginModelProvidersCachePayload.__name__])
|
||||
def post(self):
|
||||
args = InvalidatePluginModelProvidersCachePayload.model_validate(inner_api_ns.payload or {})
|
||||
|
||||
for tenant_id in args.tenant_ids:
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
|
||||
return {"result": "success"}, 200
|
||||
@@ -616,23 +616,34 @@ class AdvancedChatAppGenerator(MessageBasedAppGenerator):
|
||||
message_snapshot = MessageSnapshot.from_message(message)
|
||||
session.close()
|
||||
|
||||
# return response or stream generator
|
||||
response = self._handle_advanced_chat_response(
|
||||
application_generate_entity=application_generate_entity,
|
||||
workflow=workflow_snapshot,
|
||||
queue_manager=queue_manager,
|
||||
conversation=conversation_snapshot,
|
||||
message=message_snapshot,
|
||||
user=user,
|
||||
stream=stream,
|
||||
draft_var_saver_factory=self._get_draft_var_saver_factory(
|
||||
invoke_from,
|
||||
account=user,
|
||||
tenant_id=application_generate_entity.app_config.tenant_id,
|
||||
),
|
||||
)
|
||||
try:
|
||||
response = self._handle_advanced_chat_response(
|
||||
application_generate_entity=application_generate_entity,
|
||||
workflow=workflow_snapshot,
|
||||
queue_manager=queue_manager,
|
||||
conversation=conversation_snapshot,
|
||||
message=message_snapshot,
|
||||
user=user,
|
||||
stream=stream,
|
||||
draft_var_saver_factory=self._get_draft_var_saver_factory(
|
||||
invoke_from,
|
||||
account=user,
|
||||
tenant_id=application_generate_entity.app_config.tenant_id,
|
||||
),
|
||||
)
|
||||
converted_response = AdvancedChatAppGenerateResponseConverter.convert(
|
||||
response=response,
|
||||
invoke_from=invoke_from,
|
||||
)
|
||||
except BaseException:
|
||||
self._join_worker_thread(worker_thread)
|
||||
raise
|
||||
|
||||
return AdvancedChatAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
|
||||
if isinstance(converted_response, Generator):
|
||||
return self._wrap_stream_with_worker_thread_join(converted_response, worker_thread)
|
||||
|
||||
self._join_worker_thread(worker_thread)
|
||||
return converted_response
|
||||
|
||||
def _generate_worker(
|
||||
self,
|
||||
|
||||
@@ -538,6 +538,7 @@ class AgentAppGenerator(MessageBasedAppGenerator):
|
||||
request_builder=AgentAppRuntimeRequestBuilder(credentials_provider=credentials_provider),
|
||||
agent_backend_client=create_agent_backend_run_client(
|
||||
base_url=dify_config.AGENT_BACKEND_BASE_URL,
|
||||
api_token=dify_config.AGENT_BACKEND_API_TOKEN,
|
||||
use_fake=dify_config.AGENT_BACKEND_USE_FAKE,
|
||||
fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO,
|
||||
stream_read_timeout_seconds=dify_config.AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import Generator, Mapping, Sequence
|
||||
from contextlib import AbstractContextManager, nullcontext
|
||||
from typing import TYPE_CHECKING, Any, Union, final
|
||||
@@ -23,6 +25,10 @@ from services.workflow_draft_variable_service import DraftVariableSaver as Draft
|
||||
if TYPE_CHECKING:
|
||||
from graphon.variables.input_entities import VariableEntity
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_WORKER_THREAD_JOIN_TIMEOUT_SECONDS = 300
|
||||
|
||||
|
||||
@final
|
||||
class _DebuggerDraftVariableSaver:
|
||||
@@ -64,6 +70,29 @@ class _DebuggerDraftVariableSaver:
|
||||
class BaseAppGenerator:
|
||||
_file_access_controller: DatabaseFileAccessController = DatabaseFileAccessController()
|
||||
|
||||
@staticmethod
|
||||
def _join_worker_thread(worker_thread: threading.Thread) -> None:
|
||||
# Bound the wait so a leaked app worker cannot occupy an execution slot indefinitely.
|
||||
worker_thread.join(timeout=_WORKER_THREAD_JOIN_TIMEOUT_SECONDS)
|
||||
if worker_thread.is_alive():
|
||||
logger.warning(
|
||||
"Possible app worker thread leak: thread_name=%s timeout_seconds=%s; "
|
||||
"continuing without waiting further to avoid occupying an execution slot indefinitely",
|
||||
worker_thread.name,
|
||||
_WORKER_THREAD_JOIN_TIMEOUT_SECONDS,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _wrap_stream_with_worker_thread_join[ResponseT](
|
||||
response_stream: Generator[ResponseT, None, None],
|
||||
worker_thread: threading.Thread,
|
||||
) -> Generator[ResponseT, None, None]:
|
||||
"""Keep the producer owned by the response stream until both finish."""
|
||||
try:
|
||||
yield from response_stream
|
||||
finally:
|
||||
BaseAppGenerator._join_worker_thread(worker_thread)
|
||||
|
||||
@staticmethod
|
||||
def _bind_file_access_scope(
|
||||
*,
|
||||
|
||||
@@ -351,17 +351,28 @@ class PipelineGenerator(BaseAppGenerator):
|
||||
user,
|
||||
tenant_id=pipeline.tenant_id,
|
||||
)
|
||||
# return response or stream generator
|
||||
response = self._handle_response(
|
||||
application_generate_entity=application_generate_entity,
|
||||
workflow=workflow,
|
||||
queue_manager=queue_manager,
|
||||
user=user,
|
||||
stream=streaming,
|
||||
draft_var_saver_factory=draft_var_saver_factory,
|
||||
)
|
||||
try:
|
||||
response = self._handle_response(
|
||||
application_generate_entity=application_generate_entity,
|
||||
workflow=workflow,
|
||||
queue_manager=queue_manager,
|
||||
user=user,
|
||||
stream=streaming,
|
||||
draft_var_saver_factory=draft_var_saver_factory,
|
||||
)
|
||||
converted_response = WorkflowAppGenerateResponseConverter.convert(
|
||||
response=response,
|
||||
invoke_from=invoke_from,
|
||||
)
|
||||
except BaseException:
|
||||
self._join_worker_thread(worker_thread)
|
||||
raise
|
||||
|
||||
return WorkflowAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
|
||||
if isinstance(converted_response, Generator):
|
||||
return self._wrap_stream_with_worker_thread_join(converted_response, worker_thread)
|
||||
|
||||
self._join_worker_thread(worker_thread)
|
||||
return converted_response
|
||||
|
||||
def single_iteration_generate(
|
||||
self,
|
||||
|
||||
@@ -405,17 +405,28 @@ class WorkflowAppGenerator(BaseAppGenerator):
|
||||
tenant_id=app_model.tenant_id,
|
||||
)
|
||||
|
||||
# return response or stream generator
|
||||
response = self._handle_response(
|
||||
application_generate_entity=application_generate_entity,
|
||||
workflow=workflow,
|
||||
queue_manager=queue_manager,
|
||||
user=user,
|
||||
draft_var_saver_factory=draft_var_saver_factory,
|
||||
stream=streaming,
|
||||
)
|
||||
try:
|
||||
response = self._handle_response(
|
||||
application_generate_entity=application_generate_entity,
|
||||
workflow=workflow,
|
||||
queue_manager=queue_manager,
|
||||
user=user,
|
||||
draft_var_saver_factory=draft_var_saver_factory,
|
||||
stream=streaming,
|
||||
)
|
||||
converted_response = WorkflowAppGenerateResponseConverter.convert(
|
||||
response=response,
|
||||
invoke_from=invoke_from,
|
||||
)
|
||||
except BaseException:
|
||||
self._join_worker_thread(worker_thread)
|
||||
raise
|
||||
|
||||
return WorkflowAppGenerateResponseConverter.convert(response=response, invoke_from=invoke_from)
|
||||
if isinstance(converted_response, Generator):
|
||||
return self._wrap_stream_with_worker_thread_join(converted_response, worker_thread)
|
||||
|
||||
self._join_worker_thread(worker_thread)
|
||||
return converted_response
|
||||
|
||||
def single_iteration_generate(
|
||||
self,
|
||||
|
||||
@@ -434,14 +434,18 @@ class PluginService:
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _fetch_plugin_model_providers_uncached(
|
||||
cls, tenant_id: str, client: PluginModelClient | None
|
||||
) -> tuple[ProviderEntity, ...]:
|
||||
model_client = client or PluginModelClient()
|
||||
return tuple(cls._to_provider_entity(provider) for provider in model_client.fetch_model_providers(tenant_id))
|
||||
|
||||
@classmethod
|
||||
def _fetch_and_cache_plugin_model_providers(
|
||||
cls, tenant_id: str, client: PluginModelClient | None, *, refresh_generation: int | None
|
||||
) -> tuple[ProviderEntity, ...]:
|
||||
model_client = client or PluginModelClient()
|
||||
providers = tuple(
|
||||
cls._to_provider_entity(provider) for provider in model_client.fetch_model_providers(tenant_id)
|
||||
)
|
||||
providers = cls._fetch_plugin_model_providers_uncached(tenant_id, client)
|
||||
generation = cls._load_plugin_model_providers_generation(tenant_id)
|
||||
if generation is not None and generation == refresh_generation:
|
||||
cls._store_cached_plugin_model_providers(tenant_id, generation, providers)
|
||||
@@ -471,6 +475,9 @@ class PluginService:
|
||||
are intentionally owned by this service so tenant isolation and cache
|
||||
expiry are handled in one place.
|
||||
"""
|
||||
if not dify_config.PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED:
|
||||
return cls._fetch_plugin_model_providers_uncached(tenant_id, client)
|
||||
|
||||
deadline = time.monotonic() + cls.PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT
|
||||
|
||||
while True:
|
||||
|
||||
@@ -2023,6 +2023,8 @@ class DatasetRetrieval:
|
||||
redis_client.zremrangebyscore(key, 0, current_time - 60000)
|
||||
request_count = redis_client.zcard(key)
|
||||
if request_count > knowledge_rate_limit.limit:
|
||||
# The rate-limit exception is raised after this block, so commit the audit row
|
||||
# explicitly instead of relying on the Session context, which only closes it.
|
||||
with session_factory.create_session() as session:
|
||||
rate_limit_log = RateLimitLog(
|
||||
tenant_id=tenant_id,
|
||||
@@ -2030,6 +2032,7 @@ class DatasetRetrieval:
|
||||
operation="knowledge",
|
||||
)
|
||||
session.add(rate_limit_log)
|
||||
session.commit()
|
||||
raise exc.RateLimitExceededError(
|
||||
"you have reached the knowledge base request rate limit of your subscription."
|
||||
)
|
||||
|
||||
@@ -107,6 +107,8 @@ class MCPTool(Tool):
|
||||
if self.entity.output_schema and result.structuredContent:
|
||||
for k, v in result.structuredContent.items():
|
||||
yield self.create_variable_message(k, v)
|
||||
elif result.structuredContent:
|
||||
yield self.create_json_message(result.structuredContent)
|
||||
|
||||
def _process_text_content(self, content: TextContent) -> Generator[ToolInvokeMessage, None, None]:
|
||||
"""Process text content and yield appropriate messages."""
|
||||
|
||||
@@ -497,6 +497,7 @@ class DifyNodeFactory(NodeFactory):
|
||||
),
|
||||
"agent_backend_client": create_agent_backend_run_client(
|
||||
base_url=dify_config.AGENT_BACKEND_BASE_URL,
|
||||
api_token=dify_config.AGENT_BACKEND_API_TOKEN,
|
||||
use_fake=dify_config.AGENT_BACKEND_USE_FAKE,
|
||||
fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO,
|
||||
stream_read_timeout_seconds=dify_config.AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS,
|
||||
|
||||
+2
-2
@@ -1117,14 +1117,14 @@ class ExporleBanner(TypeBase):
|
||||
status: Mapped[BannerStatus] = mapped_column(
|
||||
EnumText(BannerStatus, length=255),
|
||||
nullable=False,
|
||||
server_default=sa.text("'enabled'::character varying"),
|
||||
server_default=sa.text("'enabled'"),
|
||||
default=BannerStatus.ENABLED,
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
sa.DateTime, nullable=False, server_default=func.current_timestamp(), init=False
|
||||
)
|
||||
language: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False, server_default=sa.text("'en-US'::character varying"), default="en-US"
|
||||
String(255), nullable=False, server_default=sa.text("'en-US'"), default="en-US"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1672,6 +1672,23 @@ Create a new application
|
||||
| 200 | Import confirmed | **application/json**: [Import](#import)<br> |
|
||||
| 400 | Import failed | **application/json**: [Import](#import)<br> |
|
||||
|
||||
### [GET] /apps/recent
|
||||
**Return the lightweight app cards needed by the Explore home page**
|
||||
|
||||
Get recently modified apps for the home Continue Work section
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Located in | Description | Required | Schema |
|
||||
| ---- | ---------- | ----------- | -------- | ------ |
|
||||
| limit | query | Number of recently modified apps to return (1-8) | No | integer, <br>**Default:** 8 |
|
||||
|
||||
#### Responses
|
||||
|
||||
| Code | Description | Schema |
|
||||
| ---- | ----------- | ------ |
|
||||
| 200 | Success | **application/json**: [RecentAppListResponse](#recentapplistresponse)<br> |
|
||||
|
||||
### [GET] /apps/starred
|
||||
Get applications starred by the current account
|
||||
|
||||
@@ -21018,6 +21035,28 @@ Whitelist scopes accepted by RBAC app and dataset access config APIs.
|
||||
| result | string | | Yes |
|
||||
| updated_at | integer | | Yes |
|
||||
|
||||
#### RecentAppListResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| data | [ [RecentAppResponse](#recentappresponse) ] | | Yes |
|
||||
|
||||
#### RecentAppResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
| ---- | ---- | ----------- | -------- |
|
||||
| author_name | string | | No |
|
||||
| icon | string | | No |
|
||||
| icon_background | string | | No |
|
||||
| icon_type | [IconType](#icontype) | | No |
|
||||
| icon_url | string | | Yes |
|
||||
| id | string | | Yes |
|
||||
| maintainer | string | | No |
|
||||
| mode | string, <br>**Available values:** "advanced-chat", "agent-chat", "chat", "completion", "workflow" | *Enum:* `"advanced-chat"`, `"agent-chat"`, `"chat"`, `"completion"`, `"workflow"` | Yes |
|
||||
| name | string | | Yes |
|
||||
| permission_keys | [ string ] | | No |
|
||||
| updated_at | integer | | Yes |
|
||||
|
||||
#### RecommendedAppDetailNullableResponse
|
||||
|
||||
| Name | Type | Description | Required |
|
||||
|
||||
+4
-4
@@ -1,12 +1,12 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.16.0"
|
||||
version = "1.16.1"
|
||||
requires-python = "~=3.12.0"
|
||||
|
||||
dependencies = [
|
||||
# Legacy: mature and widely deployed
|
||||
"bleach>=6.4.0,<7.0.0",
|
||||
"boto3>=1.43.46,<2.0.0",
|
||||
"boto3>=1.43.56,<2.0.0",
|
||||
"celery>=5.6.3,<6.0.0",
|
||||
"croniter>=6.2.2,<7.0.0",
|
||||
"dify-agent",
|
||||
@@ -193,10 +193,10 @@ dev = [
|
||||
############################################################
|
||||
storage = [
|
||||
"azure-storage-blob>=12.30.0,<13.0.0",
|
||||
"bce-python-sdk==0.9.72",
|
||||
"bce-python-sdk==0.9.76",
|
||||
"cos-python-sdk-v5>=1.9.44,<2.0.0",
|
||||
"esdk-obs-python>=3.26.6,<4.0.0",
|
||||
"google-cloud-storage>=3.12.1,<4.0.0",
|
||||
"google-cloud-storage>=3.13.0,<4.0.0",
|
||||
"opendal==0.46.0",
|
||||
"oss2>=2.19.1,<3.0.0",
|
||||
"supabase>=2.31.0,<3.0.0",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any, Literal, NotRequired, TypedDict, cast, override
|
||||
|
||||
@@ -41,6 +42,20 @@ from tasks.remove_app_and_related_data_task import remove_app_and_related_data_t
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
AppListSortBy = Literal["last_modified", "recently_created", "earliest_created"]
|
||||
RecentAppMode = Literal[
|
||||
AppMode.COMPLETION,
|
||||
AppMode.WORKFLOW,
|
||||
AppMode.CHAT,
|
||||
AppMode.ADVANCED_CHAT,
|
||||
AppMode.AGENT_CHAT,
|
||||
]
|
||||
RECENT_APP_MODES: tuple[RecentAppMode, ...] = (
|
||||
AppMode.COMPLETION,
|
||||
AppMode.WORKFLOW,
|
||||
AppMode.CHAT,
|
||||
AppMode.ADVANCED_CHAT,
|
||||
AppMode.AGENT_CHAT,
|
||||
)
|
||||
|
||||
|
||||
class AppListBaseParams(BaseModel):
|
||||
@@ -65,6 +80,19 @@ class StarredAppListParams(AppListBaseParams):
|
||||
pass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RecentAppListItem:
|
||||
id: str
|
||||
name: str
|
||||
icon_type: IconType | None
|
||||
icon: str | None
|
||||
icon_background: str | None
|
||||
mode: RecentAppMode
|
||||
author_name: str | None
|
||||
updated_at: datetime
|
||||
maintainer: str | None
|
||||
|
||||
|
||||
class CreateAppParams(BaseModel):
|
||||
name: str = Field(min_length=1)
|
||||
description: str | None = None
|
||||
@@ -323,6 +351,62 @@ class AppService:
|
||||
|
||||
return app_models
|
||||
|
||||
def get_recent_apps(
|
||||
self,
|
||||
user_id: str,
|
||||
tenant_id: str,
|
||||
params: AppListParams,
|
||||
session: Session,
|
||||
) -> list[RecentAppListItem]:
|
||||
"""Return recently modified apps as one lightweight, non-paginated projection."""
|
||||
filters = self._build_app_list_filters(user_id, tenant_id, params, session)
|
||||
if not filters:
|
||||
return []
|
||||
|
||||
stmt = (
|
||||
sa.select(
|
||||
App.id,
|
||||
App.name,
|
||||
App.icon_type,
|
||||
App.icon,
|
||||
App.icon_background,
|
||||
App.mode,
|
||||
Account.name.label("author_name"),
|
||||
App.updated_at,
|
||||
App.maintainer,
|
||||
)
|
||||
.outerjoin(Account, Account.id == App.created_by)
|
||||
.where(*filters, App.mode.in_(RECENT_APP_MODES))
|
||||
.order_by(App.updated_at.desc())
|
||||
.limit(params.limit)
|
||||
)
|
||||
rows = session.execute(stmt).all()
|
||||
|
||||
return [
|
||||
RecentAppListItem(
|
||||
id=str(app_id),
|
||||
name=name,
|
||||
icon_type=icon_type,
|
||||
icon=icon,
|
||||
icon_background=icon_background,
|
||||
mode=cast(RecentAppMode, mode),
|
||||
author_name=author_name,
|
||||
updated_at=updated_at,
|
||||
maintainer=maintainer,
|
||||
)
|
||||
for (
|
||||
app_id,
|
||||
name,
|
||||
icon_type,
|
||||
icon,
|
||||
icon_background,
|
||||
mode,
|
||||
author_name,
|
||||
updated_at,
|
||||
maintainer,
|
||||
) in rows
|
||||
]
|
||||
|
||||
def get_paginate_starred_apps(
|
||||
self,
|
||||
user_id: str,
|
||||
|
||||
@@ -22,6 +22,7 @@ def _create_agent_backend_client():
|
||||
return None
|
||||
return create_agent_backend_run_client(
|
||||
base_url=dify_config.AGENT_BACKEND_BASE_URL,
|
||||
api_token=dify_config.AGENT_BACKEND_API_TOKEN,
|
||||
use_fake=dify_config.AGENT_BACKEND_USE_FAKE,
|
||||
fake_scenario=dify_config.AGENT_BACKEND_FAKE_SCENARIO,
|
||||
stream_read_timeout_seconds=dify_config.AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS,
|
||||
|
||||
@@ -457,7 +457,7 @@ def _publish_streaming_response(
|
||||
@shared_task(queue=WORKFLOW_BASED_APP_EXECUTION_QUEUE)
|
||||
def workflow_based_app_execution_task(
|
||||
payload: str,
|
||||
) -> Generator[Mapping[str, Any] | str, None, None] | Mapping[str, Any] | None:
|
||||
) -> Mapping[str, Any] | None:
|
||||
exec_params = AppExecutionParams.model_validate_json(payload)
|
||||
|
||||
logger.info("workflow_based_app_execution_task run with params: %s", exec_params)
|
||||
|
||||
@@ -4,6 +4,7 @@ from dotenv import dotenv_values
|
||||
|
||||
BASE_API_AND_DOCKER_CONFIG_SET_DIFF: frozenset[str] = frozenset(
|
||||
(
|
||||
"AGENT_BACKEND_API_TOKEN",
|
||||
"APP_MAX_EXECUTION_TIME",
|
||||
"BATCH_UPLOAD_LIMIT",
|
||||
"CELERY_BEAT_SCHEDULER_TIME",
|
||||
@@ -43,6 +44,7 @@ BASE_API_AND_DOCKER_CONFIG_SET_DIFF: frozenset[str] = frozenset(
|
||||
|
||||
BASE_API_AND_DOCKER_COMPOSE_CONFIG_SET_DIFF: frozenset[str] = frozenset(
|
||||
(
|
||||
"AGENT_BACKEND_API_TOKEN",
|
||||
"BATCH_UPLOAD_LIMIT",
|
||||
"CELERY_BEAT_SCHEDULER_TIME",
|
||||
"HTTP_REQUEST_MAX_CONNECT_TIMEOUT",
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import os
|
||||
import shutil
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.engine import URL, Engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
# Getting the absolute path of the current file's directory
|
||||
@@ -35,7 +37,7 @@ os.environ.setdefault("OPENDAL_SCHEME", "fs")
|
||||
os.environ.setdefault("OPENDAL_FS_ROOT", "/tmp/dify-storage")
|
||||
os.environ.setdefault("STORAGE_TYPE", "opendal")
|
||||
|
||||
from core.db.session_factory import configure_session_factory, session_factory
|
||||
import core.db.session_factory as session_factory_module
|
||||
from extensions import ext_redis
|
||||
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from models.base import TypeBase
|
||||
@@ -111,42 +113,70 @@ def reset_secret_key():
|
||||
dify_config.SECRET_KEY = original
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def _unit_test_engine():
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
yield engine
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sqlite_engine() -> Iterator[Engine]:
|
||||
"""Create an isolated in-memory SQLite engine for tests that need a disposable database."""
|
||||
def _sqlite_engine(_sqlite_database_template: Path, tmp_path: Path) -> Iterator[Engine]:
|
||||
"""Create an engine over a pristine per-test copy of the SQLite schema."""
|
||||
|
||||
database_path = tmp_path / "unit-tests.sqlite3"
|
||||
shutil.copyfile(_sqlite_database_template, database_path)
|
||||
engine = create_engine(URL.create("sqlite", database=str(database_path)))
|
||||
|
||||
engine = create_engine("sqlite:///:memory:")
|
||||
try:
|
||||
yield engine
|
||||
finally:
|
||||
engine.dispose()
|
||||
database_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sqlite_session(request: pytest.FixtureRequest, sqlite_engine: Engine) -> Iterator[Session]:
|
||||
"""Yield a SQLite session after creating the model tables passed through ``request.param``."""
|
||||
@pytest.fixture(scope="session")
|
||||
def _sqlite_database_template(tmp_path_factory: pytest.TempPathFactory) -> Path:
|
||||
"""Create one empty full-schema SQLite database per pytest worker."""
|
||||
|
||||
models: tuple[type[TypeBase], ...] = request.param
|
||||
tables = [model.metadata.tables[model.__tablename__] for model in models]
|
||||
TypeBase.metadata.create_all(sqlite_engine, tables=tables)
|
||||
session_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
with session_factory() as session:
|
||||
yield session
|
||||
database_path = tmp_path_factory.mktemp("sqlite-template") / "unit-tests.sqlite3"
|
||||
engine = create_engine(URL.create("sqlite", database=str(database_path)))
|
||||
try:
|
||||
TypeBase.metadata.create_all(engine)
|
||||
finally:
|
||||
engine.dispose()
|
||||
return database_path
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _configure_session_factory(_unit_test_engine):
|
||||
try:
|
||||
session_factory.get_session_maker()
|
||||
except RuntimeError:
|
||||
configure_session_factory(_unit_test_engine, expire_on_commit=False)
|
||||
def _sqlite_session_factory(
|
||||
_sqlite_engine: Engine,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> sessionmaker[Session]:
|
||||
"""Bind all unit-test Sessions to the pristine full-schema SQLite database."""
|
||||
|
||||
factory = sessionmaker(bind=_sqlite_engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(session_factory_module, "_session_maker", factory)
|
||||
return factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sqlite_engine(_sqlite_engine: Engine) -> Engine:
|
||||
"""Expose the pristine full-schema SQLite engine to tests."""
|
||||
|
||||
return _sqlite_engine
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sqlite_session_factory(_sqlite_session_factory: sessionmaker[Session]) -> sessionmaker[Session]:
|
||||
"""Expose the shared SQLite session factory to tests."""
|
||||
|
||||
return _sqlite_session_factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sqlite_session(_sqlite_session_factory: sessionmaker[Session]) -> Iterator[Session]:
|
||||
"""Yield a session over the pristine full-schema SQLite database.
|
||||
|
||||
Legacy indirect model parameters remain accepted by pytest but are ignored.
|
||||
Remove those decorators as their test files receive individual review.
|
||||
"""
|
||||
|
||||
with _sqlite_session_factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
def persist_service_api_tenant_owner(session: Session, tenant: Tenant, owner: Account) -> TenantAccountJoin:
|
||||
|
||||
@@ -708,6 +708,121 @@ def test_app_list_api_attaches_permission_keys(app, app_module):
|
||||
assert resp["data"][0]["permission_keys"] == ["app.acl.view_layout", "app.acl.edit"]
|
||||
|
||||
|
||||
def test_recent_app_list_api_returns_only_home_card_fields(app, app_module):
|
||||
method = app_module.RecentAppListApi.get
|
||||
while hasattr(method, "__wrapped__"):
|
||||
method = method.__wrapped__
|
||||
|
||||
recent_app = SimpleNamespace(
|
||||
id="app-1",
|
||||
name="Recent App",
|
||||
icon_type="emoji",
|
||||
icon="🚀",
|
||||
icon_background="#FFFFFF",
|
||||
mode="chat",
|
||||
author_name="Recent Author",
|
||||
updated_at=_ts(15),
|
||||
maintainer="acct-1",
|
||||
)
|
||||
get_recent_apps = MagicMock(return_value=[recent_app])
|
||||
|
||||
with app.test_request_context("/apps/recent?limit=8"):
|
||||
with pytest.MonkeyPatch.context() as monkeypatch:
|
||||
monkeypatch.setattr(dify_config, "RBAC_ENABLED", False)
|
||||
monkeypatch.setattr(app_module.AppService, "get_recent_apps", get_recent_apps)
|
||||
monkeypatch.setattr(
|
||||
app_module.enterprise_rbac_service.RBACService.MyPermissions,
|
||||
"get",
|
||||
lambda tenant_id, account_id, session: app_module.enterprise_rbac_service.MyPermissionsResponse(
|
||||
app=app_module.enterprise_rbac_service.ResourcePermissionSnapshot(
|
||||
overrides=[
|
||||
app_module.enterprise_rbac_service.ResourcePermissionKeys(
|
||||
resource_id="app-1",
|
||||
permission_keys=["app.acl.monitor"],
|
||||
)
|
||||
]
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
resp, status = method(app_module.RecentAppListApi(), "tenant-1", "acct-1", MagicMock())
|
||||
|
||||
assert status == 200
|
||||
assert resp == {
|
||||
"data": [
|
||||
{
|
||||
"id": "app-1",
|
||||
"name": "Recent App",
|
||||
"icon_type": "emoji",
|
||||
"icon": "🚀",
|
||||
"icon_background": "#FFFFFF",
|
||||
"mode": "chat",
|
||||
"author_name": "Recent Author",
|
||||
"updated_at": int(_ts(15).timestamp()),
|
||||
"permission_keys": ["app.acl.monitor"],
|
||||
"maintainer": "acct-1",
|
||||
"icon_url": None,
|
||||
}
|
||||
]
|
||||
}
|
||||
params = get_recent_apps.call_args.args[2]
|
||||
assert params.limit == 8
|
||||
assert "total" not in resp
|
||||
assert "description" not in resp["data"][0]
|
||||
assert "tags" not in resp["data"][0]
|
||||
assert "workflow" not in resp["data"][0]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mode", ["channel", "rag-pipeline", "agent"])
|
||||
def test_recent_app_response_rejects_non_home_app_modes(app_module, mode: str) -> None:
|
||||
with pytest.raises(ValidationError):
|
||||
app_module.RecentAppResponse.model_validate(
|
||||
{
|
||||
"id": "app-1",
|
||||
"name": "Recent App",
|
||||
"mode": mode,
|
||||
"updated_at": _ts(),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def test_recent_app_list_api_applies_rbac_visibility_filter(app, app_module):
|
||||
method = app_module.RecentAppListApi.get
|
||||
while hasattr(method, "__wrapped__"):
|
||||
method = method.__wrapped__
|
||||
|
||||
get_recent_apps = MagicMock(return_value=[])
|
||||
with app.test_request_context("/apps/recent"):
|
||||
with pytest.MonkeyPatch.context() as monkeypatch:
|
||||
monkeypatch.setattr(dify_config, "RBAC_ENABLED", True)
|
||||
monkeypatch.setattr(app_module.AppService, "get_recent_apps", get_recent_apps)
|
||||
monkeypatch.setattr(
|
||||
app_module.enterprise_rbac_service.RBACService.MyPermissions,
|
||||
"get",
|
||||
lambda tenant_id, account_id, session: app_module.enterprise_rbac_service.MyPermissionsResponse(
|
||||
workspace=app_module.enterprise_rbac_service.WorkspacePermissionSnapshot(
|
||||
permission_keys=["app.create_and_management"]
|
||||
)
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
app_module.enterprise_rbac_service.RBACService.AppAccess,
|
||||
"whitelist_resources",
|
||||
lambda tenant_id, account_id: SimpleNamespace(
|
||||
unrestricted=False,
|
||||
resource_ids=["app-shared"],
|
||||
),
|
||||
)
|
||||
|
||||
resp, status = method(app_module.RecentAppListApi(), "tenant-1", "acct-1", MagicMock())
|
||||
|
||||
assert status == 200
|
||||
assert resp == {"data": []}
|
||||
params = get_recent_apps.call_args.args[2]
|
||||
assert params.accessible_app_ids == ["app-shared"]
|
||||
assert params.include_own_apps is True
|
||||
|
||||
|
||||
def test_app_list_api_limits_to_apps_created_by_current_user_without_view_permission(app, app_module):
|
||||
method = app_module.AppListApi.get
|
||||
while hasattr(method, "__wrapped__"):
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
import inspect
|
||||
from unittest.mock import call, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from pydantic import ValidationError
|
||||
|
||||
from controllers.inner_api.workspace.plugin_model_providers import (
|
||||
EnterprisePluginModelProvidersCacheInvalidate,
|
||||
InvalidatePluginModelProvidersCachePayload,
|
||||
)
|
||||
|
||||
|
||||
class TestInvalidatePluginModelProvidersCachePayload:
|
||||
def test_valid_payload(self):
|
||||
payload = InvalidatePluginModelProvidersCachePayload.model_validate(
|
||||
{"tenant_ids": ["tenant-alpha", "tenant-beta"]}
|
||||
)
|
||||
assert payload.tenant_ids == ["tenant-alpha", "tenant-beta"]
|
||||
|
||||
def test_missing_tenant_ids_defaults_to_empty(self):
|
||||
payload = InvalidatePluginModelProvidersCachePayload.model_validate({})
|
||||
assert payload.tenant_ids == []
|
||||
|
||||
def test_unknown_field_rejected(self):
|
||||
with pytest.raises(ValidationError):
|
||||
InvalidatePluginModelProvidersCachePayload.model_validate({"tenant_ids": ["tenant-alpha"], "generation": 7})
|
||||
|
||||
|
||||
class TestEnterprisePluginModelProvidersCacheInvalidate:
|
||||
@pytest.fixture
|
||||
def api_instance(self):
|
||||
return EnterprisePluginModelProvidersCacheInvalidate()
|
||||
|
||||
def _post(self, api_instance, app: Flask, payload):
|
||||
unwrapped_post = inspect.unwrap(api_instance.post)
|
||||
with app.test_request_context():
|
||||
with patch("controllers.inner_api.workspace.plugin_model_providers.inner_api_ns") as mock_ns:
|
||||
mock_ns.payload = payload
|
||||
return unwrapped_post(api_instance)
|
||||
|
||||
@patch("controllers.inner_api.workspace.plugin_model_providers.PluginService")
|
||||
def test_post_invalidates_once_per_tenant(self, mock_plugin_service, api_instance, app: Flask):
|
||||
result = self._post(api_instance, app, {"tenant_ids": ["tenant-alpha", "tenant-beta"]})
|
||||
|
||||
assert result == ({"result": "success"}, 200)
|
||||
assert mock_plugin_service.invalidate_plugin_model_providers_cache.call_args_list == [
|
||||
call("tenant-alpha"),
|
||||
call("tenant-beta"),
|
||||
]
|
||||
|
||||
@patch("controllers.inner_api.workspace.plugin_model_providers.PluginService")
|
||||
def test_post_with_empty_list_is_a_no_op(self, mock_plugin_service, api_instance, app: Flask):
|
||||
result = self._post(api_instance, app, {"tenant_ids": []})
|
||||
|
||||
assert result == ({"result": "success"}, 200)
|
||||
mock_plugin_service.invalidate_plugin_model_providers_cache.assert_not_called()
|
||||
|
||||
@patch("controllers.inner_api.workspace.plugin_model_providers.PluginService")
|
||||
def test_post_with_missing_payload_is_a_no_op(self, mock_plugin_service, api_instance, app: Flask):
|
||||
result = self._post(api_instance, app, None)
|
||||
|
||||
assert result == ({"result": "success"}, 200)
|
||||
mock_plugin_service.invalidate_plugin_model_providers_cache.assert_not_called()
|
||||
@@ -1,17 +1,31 @@
|
||||
"""
|
||||
Unit tests for Service API File Preview endpoint
|
||||
"""Unit tests for the Service API file-preview endpoint.
|
||||
|
||||
Ownership checks run against persisted message, file, app, and upload rows so the
|
||||
tests exercise the same SQLAlchemy statements and tenant boundary as production.
|
||||
Storage remains mocked because it is the external I/O boundary of the endpoint.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
from typing import Protocol, cast
|
||||
from unittest.mock import Mock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from controllers.service_api.app.error import FileAccessDeniedError, FileNotFoundError
|
||||
from controllers.service_api.app.file_preview import FilePreviewApi
|
||||
from models.model import App, EndUser, Message, MessageFile, UploadFile
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from graphon.file import FileTransferMethod, FileType
|
||||
from models.base import TypeBase
|
||||
from models.enums import ConversationFromSource, CreatorUserRole
|
||||
from models.model import App, AppMode, Message, MessageFile, UploadFile
|
||||
|
||||
|
||||
class _FilePreviewLogRecord(Protocol):
|
||||
@@ -20,367 +34,252 @@ class _FilePreviewLogRecord(Protocol):
|
||||
error: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _Database:
|
||||
"""Expose the real test session through the interface used by the controller."""
|
||||
|
||||
session: Session
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _PreviewRecords:
|
||||
app: App
|
||||
message: Message
|
||||
message_file: MessageFile
|
||||
upload_file: UploadFile
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database(sqlite_engine: Engine) -> Iterator[_Database]:
|
||||
"""Create only the tables required by file ownership validation."""
|
||||
|
||||
models = (App, Message, MessageFile, UploadFile)
|
||||
tables = [TypeBase.metadata.tables[model.__tablename__] for model in models]
|
||||
TypeBase.metadata.create_all(sqlite_engine, tables=tables)
|
||||
with Session(sqlite_engine, expire_on_commit=False) as session:
|
||||
yield _Database(session)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def file_preview_api() -> FilePreviewApi:
|
||||
"""Create the resource instance under test."""
|
||||
|
||||
return FilePreviewApi()
|
||||
|
||||
|
||||
def _upload_file(*, tenant_id: str, file_id: str | None = None) -> UploadFile:
|
||||
upload_file = UploadFile(
|
||||
tenant_id=tenant_id,
|
||||
storage_type=StorageType.LOCAL,
|
||||
key="storage/key/test_file.jpg",
|
||||
name="test_file.jpg",
|
||||
size=1024,
|
||||
extension="jpg",
|
||||
mime_type="image/jpeg",
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=str(uuid4()),
|
||||
created_at=datetime(2026, 1, 1),
|
||||
used=True,
|
||||
)
|
||||
if file_id is not None:
|
||||
upload_file.id = file_id
|
||||
return upload_file
|
||||
|
||||
|
||||
def _persist_preview_records(
|
||||
session: Session,
|
||||
*,
|
||||
app_id: str | None = None,
|
||||
app_tenant_id: str | None = None,
|
||||
upload_tenant_id: str | None = None,
|
||||
) -> _PreviewRecords:
|
||||
app_id = app_id or str(uuid4())
|
||||
app_tenant_id = app_tenant_id or str(uuid4())
|
||||
upload_file = _upload_file(tenant_id=upload_tenant_id or app_tenant_id)
|
||||
app = App(
|
||||
id=app_id,
|
||||
tenant_id=app_tenant_id,
|
||||
name="Preview app",
|
||||
description="",
|
||||
mode=AppMode.CHAT,
|
||||
icon_type=None,
|
||||
icon="",
|
||||
icon_background=None,
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
)
|
||||
message = Message(
|
||||
id=str(uuid4()),
|
||||
app_id=app_id,
|
||||
conversation_id=str(uuid4()),
|
||||
_inputs={},
|
||||
query="preview",
|
||||
message={},
|
||||
message_unit_price=Decimal(0),
|
||||
answer="answer",
|
||||
answer_unit_price=Decimal(0),
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.API,
|
||||
)
|
||||
message_file = MessageFile(
|
||||
message_id=message.id,
|
||||
type=FileType.IMAGE,
|
||||
transfer_method=FileTransferMethod.LOCAL_FILE,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by=str(uuid4()),
|
||||
upload_file_id=upload_file.id,
|
||||
)
|
||||
session.add_all([app, message, message_file, upload_file])
|
||||
session.commit()
|
||||
return _PreviewRecords(app=app, message=message, message_file=message_file, upload_file=upload_file)
|
||||
|
||||
|
||||
class TestFilePreviewApi:
|
||||
"""Test suite for FilePreviewApi"""
|
||||
"""Exercise ownership validation and response construction."""
|
||||
|
||||
@pytest.fixture
|
||||
def file_preview_api(self):
|
||||
"""Create FilePreviewApi instance for testing"""
|
||||
return FilePreviewApi()
|
||||
def test_validate_file_ownership_success(self, file_preview_api: FilePreviewApi, database: _Database):
|
||||
records = _persist_preview_records(database.session)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_app(self):
|
||||
"""Mock App model"""
|
||||
app = Mock(spec=App)
|
||||
app.id = str(uuid.uuid4())
|
||||
app.tenant_id = str(uuid.uuid4())
|
||||
return app
|
||||
with patch("controllers.service_api.app.file_preview.db", database):
|
||||
message_file, upload_file = file_preview_api._validate_file_ownership(
|
||||
records.upload_file.id, records.app.id
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_end_user(self):
|
||||
"""Mock EndUser model"""
|
||||
end_user = Mock(spec=EndUser)
|
||||
end_user.id = str(uuid.uuid4())
|
||||
return end_user
|
||||
assert message_file.id == records.message_file.id
|
||||
assert upload_file.id == records.upload_file.id
|
||||
assert upload_file.tenant_id == records.app.tenant_id
|
||||
|
||||
@pytest.fixture
|
||||
def mock_upload_file(self):
|
||||
"""Mock UploadFile model"""
|
||||
upload_file = Mock(spec=UploadFile)
|
||||
upload_file.id = str(uuid.uuid4())
|
||||
upload_file.name = "test_file.jpg"
|
||||
upload_file.extension = "jpg"
|
||||
upload_file.mime_type = "image/jpeg"
|
||||
upload_file.size = 1024
|
||||
upload_file.key = "storage/key/test_file.jpg"
|
||||
upload_file.tenant_id = str(uuid.uuid4())
|
||||
return upload_file
|
||||
def test_validate_file_ownership_file_not_found(self, file_preview_api: FilePreviewApi, database: _Database):
|
||||
with patch("controllers.service_api.app.file_preview.db", database):
|
||||
with pytest.raises(FileNotFoundError, match="File not found in message context"):
|
||||
file_preview_api._validate_file_ownership(str(uuid4()), str(uuid4()))
|
||||
|
||||
@pytest.fixture
|
||||
def mock_message_file(self):
|
||||
"""Mock MessageFile model"""
|
||||
message_file = Mock(spec=MessageFile)
|
||||
message_file.id = str(uuid.uuid4())
|
||||
message_file.upload_file_id = str(uuid.uuid4())
|
||||
message_file.message_id = str(uuid.uuid4())
|
||||
return message_file
|
||||
def test_validate_file_ownership_access_denied(self, file_preview_api: FilePreviewApi, database: _Database):
|
||||
records = _persist_preview_records(database.session)
|
||||
|
||||
@pytest.fixture
|
||||
def mock_message(self):
|
||||
"""Mock Message model"""
|
||||
message = Mock(spec=Message)
|
||||
message.id = str(uuid.uuid4())
|
||||
message.app_id = str(uuid.uuid4())
|
||||
return message
|
||||
with patch("controllers.service_api.app.file_preview.db", database):
|
||||
with pytest.raises(FileAccessDeniedError, match="not owned by requesting app"):
|
||||
file_preview_api._validate_file_ownership(records.upload_file.id, str(uuid4()))
|
||||
|
||||
def test_validate_file_ownership_success(
|
||||
self, file_preview_api: FilePreviewApi, mock_app, mock_upload_file, mock_message_file, mock_message
|
||||
):
|
||||
"""Test successful file ownership validation"""
|
||||
file_id = str(uuid.uuid4())
|
||||
app_id = mock_app.id
|
||||
def test_validate_file_ownership_upload_file_not_found(self, file_preview_api: FilePreviewApi, database: _Database):
|
||||
records = _persist_preview_records(database.session)
|
||||
database.session.delete(records.upload_file)
|
||||
database.session.commit()
|
||||
|
||||
# Set up the mocks
|
||||
mock_upload_file.tenant_id = mock_app.tenant_id
|
||||
mock_message.app_id = app_id
|
||||
mock_message_file.upload_file_id = file_id
|
||||
mock_message_file.message_id = mock_message.id
|
||||
with patch("controllers.service_api.app.file_preview.db", database):
|
||||
with pytest.raises(FileNotFoundError, match="Upload file record not found"):
|
||||
file_preview_api._validate_file_ownership(records.upload_file.id, records.app.id)
|
||||
|
||||
with patch("controllers.service_api.app.file_preview.db") as mock_db:
|
||||
# Mock scalar() for MessageFile and Message queries
|
||||
mock_db.session.scalar.side_effect = [
|
||||
mock_message_file, # MessageFile query
|
||||
mock_message, # Message query
|
||||
]
|
||||
# Mock get() for UploadFile and App PK lookups
|
||||
mock_db.session.get.side_effect = [
|
||||
mock_upload_file, # UploadFile query
|
||||
mock_app, # App query for tenant validation
|
||||
]
|
||||
def test_validate_file_ownership_tenant_mismatch(self, file_preview_api: FilePreviewApi, database: _Database):
|
||||
records = _persist_preview_records(database.session, upload_tenant_id=str(uuid4()))
|
||||
|
||||
# Execute the method
|
||||
result_message_file, result_upload_file = file_preview_api._validate_file_ownership(file_id, app_id)
|
||||
|
||||
# Assertions
|
||||
assert result_message_file == mock_message_file
|
||||
assert result_upload_file == mock_upload_file
|
||||
|
||||
def test_validate_file_ownership_file_not_found(self, file_preview_api: FilePreviewApi):
|
||||
"""Test file ownership validation when MessageFile not found"""
|
||||
file_id = str(uuid.uuid4())
|
||||
app_id = str(uuid.uuid4())
|
||||
|
||||
with patch("controllers.service_api.app.file_preview.db") as mock_db:
|
||||
# Mock MessageFile not found via scalar()
|
||||
mock_db.session.scalar.return_value = None
|
||||
|
||||
# Execute and assert exception
|
||||
with pytest.raises(FileNotFoundError) as exc_info:
|
||||
file_preview_api._validate_file_ownership(file_id, app_id)
|
||||
|
||||
assert "File not found in message context" in str(exc_info.value)
|
||||
|
||||
def test_validate_file_ownership_access_denied(self, file_preview_api: FilePreviewApi, mock_message_file):
|
||||
"""Test file ownership validation when Message not owned by app"""
|
||||
file_id = str(uuid.uuid4())
|
||||
app_id = str(uuid.uuid4())
|
||||
|
||||
with patch("controllers.service_api.app.file_preview.db") as mock_db:
|
||||
# Mock MessageFile found but Message not owned by app via scalar()
|
||||
mock_db.session.scalar.side_effect = [
|
||||
mock_message_file, # MessageFile query - found
|
||||
None, # Message query - not found (access denied)
|
||||
]
|
||||
|
||||
# Execute and assert exception
|
||||
with pytest.raises(FileAccessDeniedError) as exc_info:
|
||||
file_preview_api._validate_file_ownership(file_id, app_id)
|
||||
|
||||
assert "not owned by requesting app" in str(exc_info.value)
|
||||
|
||||
def test_validate_file_ownership_upload_file_not_found(
|
||||
self, file_preview_api: FilePreviewApi, mock_message_file, mock_message
|
||||
):
|
||||
"""Test file ownership validation when UploadFile not found"""
|
||||
file_id = str(uuid.uuid4())
|
||||
app_id = str(uuid.uuid4())
|
||||
|
||||
with patch("controllers.service_api.app.file_preview.db") as mock_db:
|
||||
# Mock scalar() for MessageFile and Message
|
||||
mock_db.session.scalar.side_effect = [
|
||||
mock_message_file, # MessageFile query - found
|
||||
mock_message, # Message query - found
|
||||
]
|
||||
# Mock get() for UploadFile - not found
|
||||
mock_db.session.get.return_value = None
|
||||
|
||||
# Execute and assert exception
|
||||
with pytest.raises(FileNotFoundError) as exc_info:
|
||||
file_preview_api._validate_file_ownership(file_id, app_id)
|
||||
|
||||
assert "Upload file record not found" in str(exc_info.value)
|
||||
|
||||
def test_validate_file_ownership_tenant_mismatch(
|
||||
self, file_preview_api: FilePreviewApi, mock_app, mock_upload_file, mock_message_file, mock_message
|
||||
):
|
||||
"""Test file ownership validation with tenant mismatch"""
|
||||
file_id = str(uuid.uuid4())
|
||||
app_id = mock_app.id
|
||||
|
||||
# Set up tenant mismatch
|
||||
mock_upload_file.tenant_id = "different_tenant_id"
|
||||
mock_app.tenant_id = "app_tenant_id"
|
||||
mock_message.app_id = app_id
|
||||
mock_message_file.upload_file_id = file_id
|
||||
mock_message_file.message_id = mock_message.id
|
||||
|
||||
with patch("controllers.service_api.app.file_preview.db") as mock_db:
|
||||
# Mock scalar() for MessageFile and Message queries
|
||||
mock_db.session.scalar.side_effect = [
|
||||
mock_message_file, # MessageFile query
|
||||
mock_message, # Message query
|
||||
]
|
||||
# Mock get() for UploadFile and App PK lookups
|
||||
mock_db.session.get.side_effect = [
|
||||
mock_upload_file, # UploadFile query
|
||||
mock_app, # App query for tenant validation
|
||||
]
|
||||
|
||||
# Execute and assert exception
|
||||
with pytest.raises(FileAccessDeniedError) as exc_info:
|
||||
file_preview_api._validate_file_ownership(file_id, app_id)
|
||||
|
||||
assert "tenant mismatch" in str(exc_info.value)
|
||||
with patch("controllers.service_api.app.file_preview.db", database):
|
||||
with pytest.raises(FileAccessDeniedError, match="tenant mismatch"):
|
||||
file_preview_api._validate_file_ownership(records.upload_file.id, records.app.id)
|
||||
|
||||
def test_validate_file_ownership_invalid_input(self, file_preview_api: FilePreviewApi):
|
||||
"""Test file ownership validation with invalid input"""
|
||||
|
||||
# Test with empty file_id
|
||||
with pytest.raises(FileAccessDeniedError) as exc_info:
|
||||
with pytest.raises(FileAccessDeniedError, match="Invalid file or app identifier"):
|
||||
file_preview_api._validate_file_ownership("", "app_id")
|
||||
assert "Invalid file or app identifier" in str(exc_info.value)
|
||||
|
||||
# Test with empty app_id
|
||||
with pytest.raises(FileAccessDeniedError) as exc_info:
|
||||
with pytest.raises(FileAccessDeniedError, match="Invalid file or app identifier"):
|
||||
file_preview_api._validate_file_ownership("file_id", "")
|
||||
assert "Invalid file or app identifier" in str(exc_info.value)
|
||||
|
||||
def test_build_file_response_basic(self, file_preview_api: FilePreviewApi, mock_upload_file):
|
||||
"""Test basic file response building"""
|
||||
mock_generator = Mock()
|
||||
@pytest.mark.parametrize(
|
||||
("as_attachment", "mime_type", "name", "extension", "size"),
|
||||
[
|
||||
(False, "image/jpeg", "test_file.jpg", "jpg", 1024),
|
||||
(True, "image/jpeg", "test_file.jpg", "jpg", 1024),
|
||||
(False, "text/html", "unsafe.html", "html", 1024),
|
||||
(False, "video/mp4", "test_file.mp4", "mp4", 1024),
|
||||
(False, "image/jpeg", "test_file.jpg", "jpg", 0),
|
||||
],
|
||||
)
|
||||
def test_build_file_response(
|
||||
self,
|
||||
file_preview_api: FilePreviewApi,
|
||||
as_attachment: bool,
|
||||
mime_type: str,
|
||||
name: str,
|
||||
extension: str,
|
||||
size: int,
|
||||
):
|
||||
upload_file = _upload_file(tenant_id=str(uuid4()))
|
||||
upload_file.mime_type = mime_type
|
||||
upload_file.name = name
|
||||
upload_file.extension = extension
|
||||
upload_file.size = size
|
||||
|
||||
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, False)
|
||||
response = file_preview_api._build_file_response(Mock(), upload_file, as_attachment)
|
||||
|
||||
# Check response properties
|
||||
assert response.mimetype == mock_upload_file.mime_type
|
||||
assert response.direct_passthrough is True
|
||||
assert response.headers["Content-Length"] == str(mock_upload_file.size)
|
||||
assert "Cache-Control" in response.headers
|
||||
|
||||
def test_build_file_response_as_attachment(self, file_preview_api: FilePreviewApi, mock_upload_file):
|
||||
"""Test file response building with attachment flag"""
|
||||
mock_generator = Mock()
|
||||
|
||||
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, True)
|
||||
|
||||
# Check attachment-specific headers
|
||||
assert "attachment" in response.headers["Content-Disposition"]
|
||||
assert mock_upload_file.name in response.headers["Content-Disposition"]
|
||||
assert response.headers["Content-Type"] == "application/octet-stream"
|
||||
|
||||
def test_build_file_response_html_forces_attachment(self, file_preview_api: FilePreviewApi, mock_upload_file):
|
||||
"""Test HTML files are forced to download"""
|
||||
mock_generator = Mock()
|
||||
mock_upload_file.mime_type = "text/html"
|
||||
mock_upload_file.name = "unsafe.html"
|
||||
mock_upload_file.extension = "html"
|
||||
|
||||
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, False)
|
||||
|
||||
assert "attachment" in response.headers["Content-Disposition"]
|
||||
assert response.headers["Content-Type"] == "application/octet-stream"
|
||||
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
||||
|
||||
def test_build_file_response_audio_video(self, file_preview_api: FilePreviewApi, mock_upload_file):
|
||||
"""Test file response building for audio/video files"""
|
||||
mock_generator = Mock()
|
||||
mock_upload_file.mime_type = "video/mp4"
|
||||
|
||||
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, False)
|
||||
|
||||
# Check Range support for media files
|
||||
assert response.headers["Accept-Ranges"] == "bytes"
|
||||
|
||||
def test_build_file_response_no_size(self, file_preview_api: FilePreviewApi, mock_upload_file):
|
||||
"""Test file response building when size is unknown"""
|
||||
mock_generator = Mock()
|
||||
mock_upload_file.size = 0 # Unknown size
|
||||
|
||||
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, False)
|
||||
|
||||
# Content-Length should not be set when size is unknown
|
||||
assert "Content-Length" not in response.headers
|
||||
assert ("Content-Length" in response.headers) is bool(size)
|
||||
if as_attachment or mime_type == "text/html":
|
||||
assert "attachment" in response.headers["Content-Disposition"]
|
||||
assert response.headers["Content-Type"] == "application/octet-stream"
|
||||
else:
|
||||
assert response.mimetype == mime_type
|
||||
if mime_type == "text/html":
|
||||
assert response.headers["X-Content-Type-Options"] == "nosniff"
|
||||
if mime_type.startswith("video/"):
|
||||
assert response.headers["Accept-Ranges"] == "bytes"
|
||||
|
||||
@patch("controllers.service_api.app.file_preview.storage")
|
||||
def test_get_method_integration(
|
||||
self,
|
||||
mock_storage,
|
||||
file_preview_api: FilePreviewApi,
|
||||
mock_app,
|
||||
mock_end_user,
|
||||
mock_upload_file,
|
||||
mock_message_file,
|
||||
mock_message,
|
||||
def test_components_use_validated_file(
|
||||
self, mock_storage: Mock, file_preview_api: FilePreviewApi, database: _Database
|
||||
):
|
||||
"""Test the full GET method integration (without decorator)"""
|
||||
file_id = str(uuid.uuid4())
|
||||
app_id = mock_app.id
|
||||
records = _persist_preview_records(database.session)
|
||||
generator = Mock()
|
||||
|
||||
# Set up mocks
|
||||
mock_upload_file.tenant_id = mock_app.tenant_id
|
||||
mock_message.app_id = app_id
|
||||
mock_message_file.upload_file_id = file_id
|
||||
mock_message_file.message_id = mock_message.id
|
||||
with patch("controllers.service_api.app.file_preview.db", database):
|
||||
message_file, upload_file = file_preview_api._validate_file_ownership(
|
||||
records.upload_file.id, records.app.id
|
||||
)
|
||||
response = file_preview_api._build_file_response(generator, upload_file, False)
|
||||
|
||||
mock_generator = Mock()
|
||||
mock_storage.load.return_value = mock_generator
|
||||
|
||||
with patch("controllers.service_api.app.file_preview.db") as mock_db:
|
||||
# Mock scalar() for MessageFile and Message queries
|
||||
mock_db.session.scalar.side_effect = [
|
||||
mock_message_file, # MessageFile query
|
||||
mock_message, # Message query
|
||||
]
|
||||
# Mock get() for UploadFile and App PK lookups
|
||||
mock_db.session.get.side_effect = [
|
||||
mock_upload_file, # UploadFile query
|
||||
mock_app, # App query for tenant validation
|
||||
]
|
||||
|
||||
# Test the core logic directly without Flask decorators
|
||||
# Validate file ownership
|
||||
result_message_file, result_upload_file = file_preview_api._validate_file_ownership(file_id, app_id)
|
||||
assert result_message_file == mock_message_file
|
||||
assert result_upload_file == mock_upload_file
|
||||
|
||||
# Test file response building
|
||||
response = file_preview_api._build_file_response(mock_generator, mock_upload_file, False)
|
||||
assert response is not None
|
||||
|
||||
# Verify storage was called correctly
|
||||
mock_storage.load.assert_not_called() # Since we're testing components separately
|
||||
assert message_file.id == records.message_file.id
|
||||
assert response.mimetype == "image/jpeg"
|
||||
mock_storage.load.assert_not_called()
|
||||
|
||||
@patch("controllers.service_api.app.file_preview.storage")
|
||||
def test_storage_error_handling(
|
||||
self,
|
||||
mock_storage,
|
||||
file_preview_api: FilePreviewApi,
|
||||
mock_app,
|
||||
mock_upload_file,
|
||||
mock_message_file,
|
||||
mock_message,
|
||||
def test_storage_error_remains_external(
|
||||
self, mock_storage: Mock, file_preview_api: FilePreviewApi, database: _Database
|
||||
):
|
||||
"""Test storage error handling in the core logic"""
|
||||
file_id = str(uuid.uuid4())
|
||||
app_id = mock_app.id
|
||||
records = _persist_preview_records(database.session)
|
||||
mock_storage.load.side_effect = OSError("Storage error")
|
||||
|
||||
# Set up mocks
|
||||
mock_upload_file.tenant_id = mock_app.tenant_id
|
||||
mock_message.app_id = app_id
|
||||
mock_message_file.upload_file_id = file_id
|
||||
mock_message_file.message_id = mock_message.id
|
||||
with patch("controllers.service_api.app.file_preview.db", database):
|
||||
_, upload_file = file_preview_api._validate_file_ownership(records.upload_file.id, records.app.id)
|
||||
|
||||
# Mock storage error
|
||||
mock_storage.load.side_effect = Exception("Storage error")
|
||||
|
||||
with patch("controllers.service_api.app.file_preview.db") as mock_db:
|
||||
# Mock scalar() for MessageFile and Message queries
|
||||
mock_db.session.scalar.side_effect = [
|
||||
mock_message_file, # MessageFile query
|
||||
mock_message, # Message query
|
||||
]
|
||||
# Mock get() for UploadFile and App PK lookups
|
||||
mock_db.session.get.side_effect = [
|
||||
mock_upload_file, # UploadFile query
|
||||
mock_app, # App query for tenant validation
|
||||
]
|
||||
|
||||
# First validate file ownership works
|
||||
result_message_file, result_upload_file = file_preview_api._validate_file_ownership(file_id, app_id)
|
||||
assert result_message_file == mock_message_file
|
||||
assert result_upload_file == mock_upload_file
|
||||
|
||||
# Test storage error handling
|
||||
with pytest.raises(Exception) as exc_info:
|
||||
mock_storage.load(mock_upload_file.key, stream=True)
|
||||
|
||||
assert "Storage error" in str(exc_info.value)
|
||||
with pytest.raises(OSError, match="Storage error"):
|
||||
mock_storage.load(upload_file.key, stream=True)
|
||||
|
||||
def test_validate_file_ownership_unexpected_error_logging(
|
||||
self, file_preview_api: FilePreviewApi, caplog: pytest.LogCaptureFixture
|
||||
self,
|
||||
file_preview_api: FilePreviewApi,
|
||||
database: _Database,
|
||||
sqlite_engine: Engine,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
"""Test that unexpected errors are logged properly"""
|
||||
file_id = str(uuid.uuid4())
|
||||
app_id = str(uuid.uuid4())
|
||||
file_id = str(uuid4())
|
||||
app_id = str(uuid4())
|
||||
|
||||
with patch("controllers.service_api.app.file_preview.db") as mock_db:
|
||||
# Mock database scalar to raise unexpected exception
|
||||
mock_db.session.scalar.side_effect = Exception("Unexpected database error")
|
||||
def fail_statement(*_args: object) -> None:
|
||||
raise RuntimeError("Unexpected database error")
|
||||
|
||||
# Execute and assert exception
|
||||
with caplog.at_level(logging.ERROR, logger="controllers.service_api.app.file_preview"):
|
||||
with pytest.raises(FileAccessDeniedError) as exc_info:
|
||||
file_preview_api._validate_file_ownership(file_id, app_id)
|
||||
event.listen(sqlite_engine, "before_cursor_execute", fail_statement)
|
||||
try:
|
||||
with patch("controllers.service_api.app.file_preview.db", database):
|
||||
with caplog.at_level(logging.ERROR, logger="controllers.service_api.app.file_preview"):
|
||||
with pytest.raises(FileAccessDeniedError, match="File access validation failed"):
|
||||
file_preview_api._validate_file_ownership(file_id, app_id)
|
||||
finally:
|
||||
event.remove(sqlite_engine, "before_cursor_execute", fail_statement)
|
||||
|
||||
# Verify error message
|
||||
assert "File access validation failed" in str(exc_info.value)
|
||||
|
||||
# Verify logging was called with the structured context fields. The ``extra`` keys
|
||||
# are attached to the LogRecord as attributes, so they are not in ``caplog.text``.
|
||||
assert len(caplog.records) == 1
|
||||
log_record = caplog.records[0]
|
||||
assert log_record.getMessage() == "Unexpected error during file ownership validation"
|
||||
record = cast(_FilePreviewLogRecord, log_record)
|
||||
assert record.file_id == file_id
|
||||
assert record.app_id == app_id
|
||||
assert record.error == "Unexpected database error"
|
||||
assert len(caplog.records) == 1
|
||||
log_record = caplog.records[0]
|
||||
assert log_record.getMessage() == "Unexpected error during file ownership validation"
|
||||
record = cast(_FilePreviewLogRecord, log_record)
|
||||
assert record.file_id == file_id
|
||||
assert record.app_id == app_id
|
||||
assert record.error == "Unexpected database error"
|
||||
|
||||
@@ -442,6 +442,13 @@ class TestAdvancedChatAppGeneratorInternals:
|
||||
def start(self):
|
||||
thread_data["started"] = True
|
||||
|
||||
def join(self, timeout):
|
||||
thread_data["joined"] = True
|
||||
thread_data["join_timeout"] = timeout
|
||||
|
||||
def is_alive(self):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.threading.Thread", _Thread)
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.advanced_chat.app_generator.db", SimpleNamespace(engine=object(), session=db_session)
|
||||
@@ -475,6 +482,8 @@ class TestAdvancedChatAppGeneratorInternals:
|
||||
|
||||
assert response["response"] == {"raw": True}
|
||||
assert thread_data["started"] is True
|
||||
assert thread_data["joined"] is True
|
||||
assert thread_data["join_timeout"] == 300
|
||||
assert "pause-layer" in thread_data["kwargs"]["graph_engine_layers"]
|
||||
assert generator._dialogue_count == 3
|
||||
assert init_records.call_args.kwargs["session"] is db_session
|
||||
@@ -542,6 +551,13 @@ class TestAdvancedChatAppGeneratorInternals:
|
||||
def start(self):
|
||||
thread_data["started"] = True
|
||||
|
||||
def join(self, timeout):
|
||||
thread_data["joined"] = True
|
||||
thread_data["join_timeout"] = timeout
|
||||
|
||||
def is_alive(self):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("core.app.apps.advanced_chat.app_generator.threading.Thread", _Thread)
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.advanced_chat.app_generator.db", SimpleNamespace(engine=object(), session=db_session)
|
||||
@@ -574,6 +590,8 @@ class TestAdvancedChatAppGeneratorInternals:
|
||||
init_records.assert_not_called()
|
||||
get_thread_messages_length.assert_called_once_with(conversation.id, session=db_session)
|
||||
assert thread_data["started"] is True
|
||||
assert thread_data["joined"] is True
|
||||
assert thread_data["join_timeout"] == 300
|
||||
db_session.commit.assert_not_called()
|
||||
db_session.refresh.assert_not_called()
|
||||
db_session.close.assert_called_once()
|
||||
|
||||
@@ -435,6 +435,7 @@ def test_generate_success_returns_converted(generator, mocker: MockerFixture):
|
||||
mocker.patch.object(module, "PipelineQueueManager", return_value=queue_manager)
|
||||
|
||||
worker_thread = MagicMock()
|
||||
worker_thread.is_alive.return_value = False
|
||||
mocker.patch.object(module.threading, "Thread", return_value=worker_thread)
|
||||
|
||||
mocker.patch.object(generator, "_get_draft_var_saver_factory", return_value=MagicMock())
|
||||
@@ -461,6 +462,7 @@ def test_generate_success_returns_converted(generator, mocker: MockerFixture):
|
||||
)
|
||||
|
||||
assert result == "converted"
|
||||
worker_thread.join.assert_called_once_with(timeout=300)
|
||||
|
||||
|
||||
def test_single_iteration_generate_validates_inputs(generator, mocker: MockerFixture):
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import logging
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.apps.base_app_generator import BaseAppGenerator
|
||||
@@ -369,6 +372,58 @@ def test_validate_inputs_optional_file_with_empty_string_ignores_default():
|
||||
|
||||
|
||||
class TestBaseAppGeneratorExtras:
|
||||
def test_wrap_stream_joins_worker_after_stream_exhaustion(self):
|
||||
base_app_generator = BaseAppGenerator()
|
||||
worker_thread = Mock()
|
||||
worker_thread.is_alive.return_value = False
|
||||
|
||||
def response_stream():
|
||||
yield {"event": "workflow_finished"}
|
||||
|
||||
managed_stream = base_app_generator._wrap_stream_with_worker_thread_join(
|
||||
response_stream(),
|
||||
worker_thread,
|
||||
)
|
||||
|
||||
assert next(managed_stream) == {"event": "workflow_finished"}
|
||||
worker_thread.join.assert_not_called()
|
||||
|
||||
with pytest.raises(StopIteration):
|
||||
next(managed_stream)
|
||||
|
||||
worker_thread.join.assert_called_once_with(timeout=300)
|
||||
|
||||
def test_wrap_stream_joins_worker_when_stream_closes(self):
|
||||
base_app_generator = BaseAppGenerator()
|
||||
worker_thread = Mock()
|
||||
worker_thread.is_alive.return_value = False
|
||||
|
||||
def response_stream():
|
||||
yield {"event": "workflow_started"}
|
||||
yield {"event": "workflow_finished"}
|
||||
|
||||
managed_stream = base_app_generator._wrap_stream_with_worker_thread_join(
|
||||
response_stream(),
|
||||
worker_thread,
|
||||
)
|
||||
|
||||
assert next(managed_stream) == {"event": "workflow_started"}
|
||||
managed_stream.close()
|
||||
|
||||
worker_thread.join.assert_called_once_with(timeout=300)
|
||||
|
||||
def test_join_worker_thread_warns_when_thread_remains_alive(self, caplog: pytest.LogCaptureFixture):
|
||||
worker_thread = Mock()
|
||||
worker_thread.name = "leaked-app-worker"
|
||||
worker_thread.is_alive.return_value = True
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="core.app.apps.base_app_generator"):
|
||||
BaseAppGenerator._join_worker_thread(worker_thread)
|
||||
|
||||
worker_thread.join.assert_called_once_with(timeout=300)
|
||||
assert "Possible app worker thread leak" in caplog.text
|
||||
assert "leaked-app-worker" in caplog.text
|
||||
|
||||
def test_prepare_user_inputs_converts_files_and_lists(self, monkeypatch: pytest.MonkeyPatch):
|
||||
base_app_generator = BaseAppGenerator()
|
||||
|
||||
|
||||
@@ -211,6 +211,13 @@ def test_generate_appends_pause_layer_and_forwards_state(mocker: MockerFixture):
|
||||
def start(self):
|
||||
return None
|
||||
|
||||
def join(self, timeout):
|
||||
worker_kwargs["joined"] = True
|
||||
worker_kwargs["join_timeout"] = timeout
|
||||
|
||||
def is_alive(self):
|
||||
return False
|
||||
|
||||
mocker.patch("core.app.apps.workflow.app_generator.threading.Thread", DummyThread)
|
||||
|
||||
app_model = SimpleNamespace(mode="workflow", tenant_id="tenant")
|
||||
@@ -244,6 +251,8 @@ def test_generate_appends_pause_layer_and_forwards_state(mocker: MockerFixture):
|
||||
assert result == "converted"
|
||||
assert worker_kwargs["kwargs"]["graph_engine_layers"] == ("base-layer", pause_layer)
|
||||
assert worker_kwargs["kwargs"]["graph_runtime_state"] is graph_runtime_state
|
||||
assert worker_kwargs["joined"] is True
|
||||
assert worker_kwargs["join_timeout"] == 300
|
||||
assert draft_saver_factory.call_args.kwargs["tenant_id"] == app_model.tenant_id
|
||||
|
||||
|
||||
@@ -286,6 +295,8 @@ def test_resume_path_runs_worker_with_runtime_state(mocker: MockerFixture):
|
||||
|
||||
mocker.patch("core.app.apps.workflow.app_generator.WorkflowAppRunner", side_effect=runner_ctor)
|
||||
|
||||
worker_lifecycle: dict[str, bool] = {}
|
||||
|
||||
class ImmediateThread:
|
||||
def __init__(self, target, kwargs):
|
||||
target(**kwargs)
|
||||
@@ -293,6 +304,13 @@ def test_resume_path_runs_worker_with_runtime_state(mocker: MockerFixture):
|
||||
def start(self):
|
||||
return None
|
||||
|
||||
def join(self, timeout):
|
||||
worker_lifecycle["joined"] = True
|
||||
worker_lifecycle["join_timeout"] = timeout
|
||||
|
||||
def is_alive(self):
|
||||
return False
|
||||
|
||||
mocker.patch("core.app.apps.workflow.app_generator.threading.Thread", ImmediateThread)
|
||||
|
||||
mocker.patch(
|
||||
@@ -331,5 +349,7 @@ def test_resume_path_runs_worker_with_runtime_state(mocker: MockerFixture):
|
||||
)
|
||||
|
||||
assert result == "raw-response"
|
||||
assert worker_lifecycle["joined"] is True
|
||||
assert worker_lifecycle["join_timeout"] == 300
|
||||
runner_instance.run.assert_called_once()
|
||||
queue_manager.graph_runtime_state = runtime_state
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import threading
|
||||
from collections.abc import Generator
|
||||
|
||||
import pytest
|
||||
|
||||
from core.app.apps.base_app_generator import BaseAppGenerator
|
||||
from core.app.apps.workflow.active_workflow_tasks import (
|
||||
active_workflow_task,
|
||||
get_active_workflow_task_count,
|
||||
@@ -28,3 +32,51 @@ def test_active_workflow_task_rejects_duplicate_task_id() -> None:
|
||||
with pytest.raises(ValueError, match="already active"):
|
||||
with active_workflow_task("task-a"):
|
||||
pass
|
||||
|
||||
|
||||
def test_managed_stream_waits_for_active_worker_cleanup() -> None:
|
||||
worker_started = threading.Event()
|
||||
release_worker = threading.Event()
|
||||
stream_exhausted = threading.Event()
|
||||
consumer_finished = threading.Event()
|
||||
consumer_errors: list[BaseException] = []
|
||||
|
||||
def run_worker() -> None:
|
||||
with active_workflow_task("task-a"):
|
||||
worker_started.set()
|
||||
release_worker.wait()
|
||||
|
||||
def response_stream() -> Generator[dict[str, str], None, None]:
|
||||
yield {"event": "workflow_finished"}
|
||||
stream_exhausted.set()
|
||||
|
||||
worker_thread = threading.Thread(target=run_worker)
|
||||
worker_thread.start()
|
||||
assert worker_started.wait(timeout=2)
|
||||
|
||||
managed_stream = BaseAppGenerator._wrap_stream_with_worker_thread_join(response_stream(), worker_thread)
|
||||
assert next(managed_stream) == {"event": "workflow_finished"}
|
||||
|
||||
def finish_stream() -> None:
|
||||
try:
|
||||
list(managed_stream)
|
||||
except BaseException as exc:
|
||||
consumer_errors.append(exc)
|
||||
finally:
|
||||
consumer_finished.set()
|
||||
|
||||
consumer_thread = threading.Thread(target=finish_stream)
|
||||
consumer_thread.start()
|
||||
try:
|
||||
assert stream_exhausted.wait(timeout=2)
|
||||
assert not consumer_finished.is_set()
|
||||
assert get_active_workflow_task_count() == 1
|
||||
finally:
|
||||
release_worker.set()
|
||||
consumer_thread.join(timeout=2)
|
||||
worker_thread.join(timeout=2)
|
||||
|
||||
assert not consumer_thread.is_alive()
|
||||
assert not worker_thread.is_alive()
|
||||
assert consumer_errors == []
|
||||
assert get_active_workflow_task_count() == 0
|
||||
|
||||
@@ -15,6 +15,70 @@ from models.model import AppMode
|
||||
|
||||
|
||||
class TestWorkflowAppGeneratorValidation:
|
||||
def test_generate_stream_joins_worker_after_response_exhaustion(self, monkeypatch: pytest.MonkeyPatch):
|
||||
generator = WorkflowAppGenerator()
|
||||
worker_thread = Mock()
|
||||
worker_thread.is_alive.return_value = False
|
||||
app_config = WorkflowUIBasedAppConfig(
|
||||
tenant_id="tenant",
|
||||
app_id="app",
|
||||
app_mode=AppMode.WORKFLOW,
|
||||
additional_features=AppAdditionalFeatures(),
|
||||
variables=[],
|
||||
workflow_id="workflow-id",
|
||||
)
|
||||
application_generate_entity = WorkflowAppGenerateEntity.model_construct(
|
||||
task_id="task",
|
||||
app_config=app_config,
|
||||
inputs={},
|
||||
files=[],
|
||||
user_id="user",
|
||||
stream=True,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
extras={},
|
||||
)
|
||||
|
||||
def response_stream():
|
||||
yield {"event": "workflow_finished"}
|
||||
|
||||
monkeypatch.setattr(generator, "_bind_file_access_scope", lambda **kwargs: contextlib.nullcontext())
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow.app_generator.WorkflowAppQueueManager",
|
||||
lambda **kwargs: SimpleNamespace(**kwargs),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow.app_generator.current_app",
|
||||
SimpleNamespace(_get_current_object=lambda: SimpleNamespace(name="flask")),
|
||||
)
|
||||
monkeypatch.setattr("core.app.apps.workflow.app_generator.contextvars.copy_context", lambda: "ctx")
|
||||
monkeypatch.setattr("core.app.apps.workflow.app_generator.threading.Thread", lambda **kwargs: worker_thread)
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow.app_generator.db",
|
||||
SimpleNamespace(session=SimpleNamespace(close=Mock())),
|
||||
)
|
||||
monkeypatch.setattr(generator, "_get_draft_var_saver_factory", lambda *args, **kwargs: "draft-factory")
|
||||
monkeypatch.setattr(generator, "_handle_response", lambda **kwargs: response_stream())
|
||||
monkeypatch.setattr(
|
||||
"core.app.apps.workflow.app_generator.WorkflowAppGenerateResponseConverter.convert",
|
||||
lambda response, invoke_from: response,
|
||||
)
|
||||
|
||||
managed_stream = generator._generate(
|
||||
app_model=SimpleNamespace(mode=AppMode.WORKFLOW, tenant_id="tenant"),
|
||||
workflow=SimpleNamespace(id="workflow-id"),
|
||||
user=SimpleNamespace(id="user"),
|
||||
application_generate_entity=application_generate_entity,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
workflow_execution_repository=SimpleNamespace(),
|
||||
workflow_node_execution_repository=SimpleNamespace(),
|
||||
streaming=True,
|
||||
)
|
||||
|
||||
worker_thread.start.assert_called_once_with()
|
||||
worker_thread.join.assert_not_called()
|
||||
assert list(managed_stream) == [{"event": "workflow_finished"}]
|
||||
worker_thread.join.assert_called_once_with(timeout=300)
|
||||
|
||||
def test_ensure_snippet_start_node_returns_original_for_non_snippet_workflow(self):
|
||||
workflow = SimpleNamespace(kind_or_standard="workflow")
|
||||
session = SimpleNamespace(scalar=Mock())
|
||||
|
||||
+235
-231
@@ -1,24 +1,95 @@
|
||||
"""Unit tests for the message cycle manager optimization."""
|
||||
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from flask import Flask, current_app
|
||||
from sqlalchemy import Engine, event, select
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.app.entities.queue_entities import QueueAnnotationReplyEvent, QueueRetrieverResourcesEvent
|
||||
from core.app.entities.task_entities import MessageStreamResponse, StreamEvent, TaskStateMetadata
|
||||
from core.app.task_pipeline import message_cycle_manager as message_cycle_manager_module
|
||||
from core.app.task_pipeline.message_cycle_manager import MessageCycleManager
|
||||
from core.rag.entities import RetrievalSourceMetadata
|
||||
from models.model import App, AppMode
|
||||
from graphon.file import FileTransferMethod, FileType
|
||||
from models import model as model_module
|
||||
from models.base import TypeBase
|
||||
from models.enums import ConversationFromSource, CreatorUserRole, MessageFileBelongsTo
|
||||
from models.model import App, AppMode, Conversation, MessageFile
|
||||
|
||||
|
||||
def _patch_create_session(mock_session):
|
||||
session_cm = Mock()
|
||||
session_cm.__enter__ = Mock(return_value=mock_session)
|
||||
session_cm.__exit__ = Mock(return_value=False)
|
||||
return patch("core.app.task_pipeline.message_cycle_manager.session_factory.create_session", return_value=session_cm)
|
||||
@dataclass(frozen=True)
|
||||
class _SQLiteDb:
|
||||
engine: Engine
|
||||
session: Session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cycle_db(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Session]:
|
||||
"""Bind request-owned and cycle-manager-owned sessions to isolated SQLite."""
|
||||
TypeBase.metadata.create_all(
|
||||
sqlite_engine,
|
||||
tables=[App.__table__, Conversation.__table__, MessageFile.__table__],
|
||||
)
|
||||
owned_session_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
with owned_session_factory() as request_session:
|
||||
sqlite_db = _SQLiteDb(engine=sqlite_engine, session=request_session)
|
||||
monkeypatch.setattr(message_cycle_manager_module, "db", sqlite_db)
|
||||
monkeypatch.setattr(model_module, "db", sqlite_db)
|
||||
monkeypatch.setattr(message_cycle_manager_module.session_factory, "create_session", owned_session_factory)
|
||||
yield request_session
|
||||
|
||||
|
||||
def _app(*, app_id: str = "app-id", tenant_id: str = "tenant-1") -> App:
|
||||
return App(
|
||||
id=app_id,
|
||||
tenant_id=tenant_id,
|
||||
name="Test App",
|
||||
description="",
|
||||
mode=AppMode.CHAT,
|
||||
enable_site=True,
|
||||
enable_api=True,
|
||||
max_active_requests=0,
|
||||
)
|
||||
|
||||
|
||||
def _conversation(*, conversation_id: str = "conv-1", app_id: str = "app-id") -> Conversation:
|
||||
conversation = Conversation(
|
||||
app_id=app_id,
|
||||
mode=AppMode.CHAT,
|
||||
name="",
|
||||
status="normal",
|
||||
from_source=ConversationFromSource.API,
|
||||
inputs={},
|
||||
)
|
||||
conversation.id = conversation_id
|
||||
return conversation
|
||||
|
||||
|
||||
def _message_file(
|
||||
*,
|
||||
file_id: str = "file-1",
|
||||
message_id: str = "test-message-id",
|
||||
belongs_to: MessageFileBelongsTo | None = MessageFileBelongsTo.ASSISTANT,
|
||||
url: str | None = "http://example.com/image.png",
|
||||
file_type: FileType = FileType.IMAGE,
|
||||
) -> MessageFile:
|
||||
message_file = MessageFile(
|
||||
message_id=message_id,
|
||||
type=file_type,
|
||||
transfer_method=FileTransferMethod.TOOL_FILE,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by="account-id",
|
||||
belongs_to=belongs_to,
|
||||
url=url,
|
||||
)
|
||||
message_file.id = file_id
|
||||
return message_file
|
||||
|
||||
|
||||
class TestMessageCycleManagerOptimization:
|
||||
@@ -37,30 +108,22 @@ class TestMessageCycleManagerOptimization:
|
||||
task_state = Mock()
|
||||
return MessageCycleManager(application_generate_entity=mock_application_generate_entity, task_state=task_state)
|
||||
|
||||
def test_get_message_event_type_with_assistant_file(self, message_cycle_manager):
|
||||
def test_get_message_event_type_with_assistant_file(self, message_cycle_manager, cycle_db: Session):
|
||||
"""Test get_message_event_type returns MESSAGE_FILE when message has assistant-generated files.
|
||||
|
||||
This ensures that AI-generated images (belongs_to='assistant') trigger the MESSAGE_FILE event,
|
||||
allowing the frontend to properly display generated image files with url field.
|
||||
"""
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
# Setup mock session and message file
|
||||
mock_session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
|
||||
cycle_db.add(_message_file())
|
||||
cycle_db.commit()
|
||||
|
||||
mock_message_file = Mock()
|
||||
mock_message_file.belongs_to = "assistant"
|
||||
mock_session.scalar.return_value = mock_message_file
|
||||
with current_app.app_context():
|
||||
result = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
|
||||
# Execute
|
||||
with current_app.app_context():
|
||||
result = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
assert result == StreamEvent.MESSAGE_FILE
|
||||
assert "test-message-id" in message_cycle_manager._message_has_file
|
||||
|
||||
# Assert
|
||||
assert result == StreamEvent.MESSAGE_FILE
|
||||
mock_session.scalar.assert_called_once()
|
||||
|
||||
def test_get_message_event_type_with_user_file(self, message_cycle_manager):
|
||||
def test_get_message_event_type_with_user_file(self, message_cycle_manager, cycle_db: Session):
|
||||
"""Test get_message_event_type returns MESSAGE when message only has user-uploaded files.
|
||||
|
||||
This is a regression test for the issue where user-uploaded images (belongs_to='user')
|
||||
@@ -68,90 +131,81 @@ class TestMessageCycleManagerOptimization:
|
||||
resulting in broken images in the chat UI. The query filters for belongs_to='assistant',
|
||||
so when only user files exist, the database query returns None, resulting in MESSAGE event type.
|
||||
"""
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
# Setup mock session and message file
|
||||
mock_session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
|
||||
cycle_db.add(_message_file(belongs_to=MessageFileBelongsTo.USER))
|
||||
cycle_db.commit()
|
||||
|
||||
# When querying for assistant files with only user files present, return None
|
||||
# (simulates database query with belongs_to='assistant' filter returning no results)
|
||||
mock_session.scalar.return_value = None
|
||||
with current_app.app_context():
|
||||
result = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
|
||||
# Execute
|
||||
with current_app.app_context():
|
||||
result = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
assert result == StreamEvent.MESSAGE
|
||||
assert "test-message-id" not in message_cycle_manager._message_has_file
|
||||
|
||||
# Assert
|
||||
assert result == StreamEvent.MESSAGE
|
||||
mock_session.scalar.assert_called_once()
|
||||
|
||||
def test_get_message_event_type_without_message_file(self, message_cycle_manager):
|
||||
def test_get_message_event_type_without_message_file(self, message_cycle_manager, cycle_db: Session):
|
||||
"""Test get_message_event_type returns MESSAGE when message has no files."""
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
# Setup mock session and no message file
|
||||
mock_session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
|
||||
# Current implementation uses session.scalar(select(...))
|
||||
mock_session.scalar.return_value = None
|
||||
assert list(cycle_db.scalars(select(MessageFile)).all()) == []
|
||||
|
||||
# Execute
|
||||
with current_app.app_context():
|
||||
result = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
with current_app.app_context():
|
||||
result = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
|
||||
# Assert
|
||||
assert result == StreamEvent.MESSAGE
|
||||
mock_session.scalar.assert_called_once()
|
||||
assert result == StreamEvent.MESSAGE
|
||||
|
||||
def test_get_message_event_type_uses_cache_without_query(self, message_cycle_manager):
|
||||
def test_get_message_event_type_uses_cache_without_query(
|
||||
self, message_cycle_manager, cycle_db: Session, sqlite_engine: Engine
|
||||
):
|
||||
"""Return MESSAGE_FILE directly from in-memory cache without opening a DB session."""
|
||||
message_cycle_manager._message_has_file.add("cached-message")
|
||||
statements: list[str] = []
|
||||
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
def record_statement(_conn, _cursor, statement, _parameters, _context, _executemany) -> None:
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
try:
|
||||
result = message_cycle_manager.get_message_event_type("cached-message")
|
||||
finally:
|
||||
event.remove(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
|
||||
assert result == StreamEvent.MESSAGE_FILE
|
||||
mock_session_factory.create_session.assert_not_called()
|
||||
assert statements == []
|
||||
|
||||
def test_message_to_stream_response_with_precomputed_event_type(self, message_cycle_manager):
|
||||
def test_message_to_stream_response_with_precomputed_event_type(self, message_cycle_manager, cycle_db: Session):
|
||||
"""MessageCycleManager.message_to_stream_response expects a valid event_type; callers should precompute it."""
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
# Setup mock session and message file
|
||||
mock_session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
|
||||
cycle_db.add(_message_file())
|
||||
cycle_db.commit()
|
||||
|
||||
mock_message_file = Mock()
|
||||
mock_message_file.belongs_to = "assistant"
|
||||
mock_session.scalar.return_value = mock_message_file
|
||||
with current_app.app_context():
|
||||
event_type = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
result = message_cycle_manager.message_to_stream_response(
|
||||
answer="Hello world", message_id="test-message-id", event_type=event_type
|
||||
)
|
||||
|
||||
# Execute: compute event type once, then pass to message_to_stream_response
|
||||
with current_app.app_context():
|
||||
event_type = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
result = message_cycle_manager.message_to_stream_response(
|
||||
answer="Hello world", message_id="test-message-id", event_type=event_type
|
||||
)
|
||||
assert isinstance(result, MessageStreamResponse)
|
||||
assert result.answer == "Hello world"
|
||||
assert result.id == "test-message-id"
|
||||
assert result.event == StreamEvent.MESSAGE_FILE
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, MessageStreamResponse)
|
||||
assert result.answer == "Hello world"
|
||||
assert result.id == "test-message-id"
|
||||
assert result.event == StreamEvent.MESSAGE_FILE
|
||||
mock_session.scalar.assert_called_once()
|
||||
|
||||
def test_message_to_stream_response_with_event_type_skips_query(self, message_cycle_manager):
|
||||
def test_message_to_stream_response_with_event_type_skips_query(
|
||||
self, message_cycle_manager, cycle_db: Session, sqlite_engine: Engine
|
||||
):
|
||||
"""Test that message_to_stream_response skips database query when event_type is provided."""
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
# Execute with event_type provided
|
||||
statements: list[str] = []
|
||||
|
||||
def record_statement(_conn, _cursor, statement, _parameters, _context, _executemany) -> None:
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
try:
|
||||
result = message_cycle_manager.message_to_stream_response(
|
||||
answer="Hello world", message_id="test-message-id", event_type=StreamEvent.MESSAGE
|
||||
)
|
||||
finally:
|
||||
event.remove(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, MessageStreamResponse)
|
||||
assert result.answer == "Hello world"
|
||||
assert result.id == "test-message-id"
|
||||
assert result.event == StreamEvent.MESSAGE
|
||||
# Should not open a session when event_type is provided
|
||||
mock_session_factory.create_session.assert_not_called()
|
||||
assert isinstance(result, MessageStreamResponse)
|
||||
assert result.answer == "Hello world"
|
||||
assert result.id == "test-message-id"
|
||||
assert result.event == StreamEvent.MESSAGE
|
||||
assert statements == []
|
||||
|
||||
def test_message_to_stream_response_with_from_variable_selector(self, message_cycle_manager):
|
||||
"""Test message_to_stream_response with from_variable_selector parameter."""
|
||||
@@ -168,40 +222,32 @@ class TestMessageCycleManagerOptimization:
|
||||
assert result.from_variable_selector == ["var1", "var2"]
|
||||
assert result.event == StreamEvent.MESSAGE
|
||||
|
||||
def test_optimization_usage_example(self, message_cycle_manager):
|
||||
def test_optimization_usage_example(self, message_cycle_manager, cycle_db: Session, sqlite_engine: Engine):
|
||||
"""Test the optimization pattern that should be used by callers."""
|
||||
# Step 1: Get event type once (this queries database)
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
mock_session = Mock()
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = mock_session
|
||||
# Current implementation uses session.scalar(select(...))
|
||||
mock_session.scalar.return_value = None # No files
|
||||
statements: list[str] = []
|
||||
|
||||
def record_statement(_conn, _cursor, statement, _parameters, _context, _executemany) -> None:
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
try:
|
||||
with current_app.app_context():
|
||||
event_type = message_cycle_manager.get_message_event_type("test-message-id")
|
||||
|
||||
# Should open session once
|
||||
mock_session_factory.create_session.assert_called_once()
|
||||
assert event_type == StreamEvent.MESSAGE
|
||||
|
||||
# Step 2: Use event_type for multiple calls (no additional queries)
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.session_factory") as mock_session_factory:
|
||||
mock_session_factory.create_session.return_value.__enter__.return_value = Mock()
|
||||
|
||||
chunk1_response = message_cycle_manager.message_to_stream_response(
|
||||
answer="Chunk 1", message_id="test-message-id", event_type=event_type
|
||||
)
|
||||
|
||||
chunk2_response = message_cycle_manager.message_to_stream_response(
|
||||
answer="Chunk 2", message_id="test-message-id", event_type=event_type
|
||||
)
|
||||
finally:
|
||||
event.remove(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
|
||||
# Should not open session again when event_type provided
|
||||
mock_session_factory.create_session.assert_not_called()
|
||||
|
||||
assert chunk1_response.event == StreamEvent.MESSAGE
|
||||
assert chunk2_response.event == StreamEvent.MESSAGE
|
||||
assert chunk1_response.answer == "Chunk 1"
|
||||
assert chunk2_response.answer == "Chunk 2"
|
||||
assert event_type == StreamEvent.MESSAGE
|
||||
assert len([statement for statement in statements if statement.lstrip().upper().startswith("SELECT")]) == 1
|
||||
assert chunk1_response.event == StreamEvent.MESSAGE
|
||||
assert chunk2_response.event == StreamEvent.MESSAGE
|
||||
assert chunk1_response.answer == "Chunk 1"
|
||||
assert chunk2_response.answer == "Chunk 2"
|
||||
|
||||
def test_generate_conversation_name_returns_none_for_completion(self, message_cycle_manager):
|
||||
"""Return None when completion entities are used for conversation naming.
|
||||
@@ -269,51 +315,38 @@ class TestMessageCycleManagerOptimization:
|
||||
assert message_cycle_manager._application_generate_entity.is_new_conversation is False
|
||||
mock_timer.assert_not_called()
|
||||
|
||||
def test_generate_conversation_name_worker_returns_when_conversation_missing(self, message_cycle_manager):
|
||||
def test_generate_conversation_name_worker_returns_when_conversation_missing(
|
||||
self, message_cycle_manager, cycle_db: Session
|
||||
):
|
||||
"""Return early when the conversation cannot be found."""
|
||||
flask_app = Flask(__name__)
|
||||
db_session = Mock()
|
||||
db_session.scalar.return_value = None
|
||||
assert list(cycle_db.scalars(select(Conversation)).all()) == []
|
||||
|
||||
with _patch_create_session(db_session):
|
||||
message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-missing", "hello")
|
||||
message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-missing", "hello")
|
||||
|
||||
db_session.commit.assert_not_called()
|
||||
assert list(cycle_db.scalars(select(Conversation)).all()) == []
|
||||
|
||||
def test_generate_conversation_name_worker_returns_when_app_missing(self, message_cycle_manager):
|
||||
def test_generate_conversation_name_worker_returns_when_app_missing(self, message_cycle_manager, cycle_db: Session):
|
||||
"""Return early when non-completion conversation has no app relation."""
|
||||
flask_app = Flask(__name__)
|
||||
conversation = SimpleNamespace(mode=AppMode.CHAT, app=None, app_id="app-id")
|
||||
db_session = Mock()
|
||||
db_session.scalar.return_value = conversation
|
||||
db_session.get.return_value = None
|
||||
conversation = _conversation()
|
||||
cycle_db.add(conversation)
|
||||
cycle_db.commit()
|
||||
|
||||
with _patch_create_session(db_session):
|
||||
message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", "hello")
|
||||
message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", "hello")
|
||||
|
||||
db_session.commit.assert_not_called()
|
||||
assert cycle_db.get(Conversation, "conv-1").name == ""
|
||||
assert cycle_db.get(App, "app-id") is None
|
||||
|
||||
def test_generate_conversation_name_worker_uses_cached_name(self, message_cycle_manager):
|
||||
def test_generate_conversation_name_worker_uses_cached_name(
|
||||
self, message_cycle_manager, cycle_db: Session, sqlite_engine: Engine
|
||||
):
|
||||
"""Use cached conversation name when present and avoid LLM call."""
|
||||
flask_app = Flask(__name__)
|
||||
|
||||
class ConversationWithPoisonedApp:
|
||||
mode = AppMode.CHAT
|
||||
app_id = "app-id"
|
||||
name = ""
|
||||
|
||||
@property
|
||||
def app(self):
|
||||
raise AssertionError("conversation.app must not open an implicit session")
|
||||
|
||||
conversation = ConversationWithPoisonedApp()
|
||||
app_model = SimpleNamespace(tenant_id="tenant-1")
|
||||
db_session = Mock()
|
||||
db_session.scalar.return_value = conversation
|
||||
db_session.get.return_value = app_model
|
||||
cycle_db.add_all([_app(), _conversation()])
|
||||
cycle_db.commit()
|
||||
|
||||
with (
|
||||
_patch_create_session(db_session) as create_session,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.redis_client") as mock_redis,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.LLMGenerator") as mock_llm_generator,
|
||||
):
|
||||
@@ -321,27 +354,23 @@ class TestMessageCycleManagerOptimization:
|
||||
|
||||
message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", "hello")
|
||||
|
||||
assert cycle_db.in_transaction() is False
|
||||
with Session(sqlite_engine) as verification_session:
|
||||
conversation = verification_session.get(Conversation, "conv-1")
|
||||
assert conversation is not None
|
||||
assert conversation.name == "cached-title"
|
||||
create_session.assert_called_once_with()
|
||||
db_session.get.assert_called_once_with(App, "app-id")
|
||||
db_session.commit.assert_called_once()
|
||||
mock_llm_generator.generate_conversation_name.assert_not_called()
|
||||
mock_redis.setex.assert_not_called()
|
||||
|
||||
def test_generate_conversation_name_worker_generates_and_caches_name(self, message_cycle_manager):
|
||||
def test_generate_conversation_name_worker_generates_and_caches_name(
|
||||
self, message_cycle_manager, cycle_db: Session, sqlite_engine: Engine
|
||||
):
|
||||
"""Generate conversation name and write it to redis cache on cache miss."""
|
||||
flask_app = Flask(__name__)
|
||||
conversation = SimpleNamespace(
|
||||
mode=AppMode.CHAT,
|
||||
app=SimpleNamespace(tenant_id="tenant-1"),
|
||||
app_id="app-id",
|
||||
name="",
|
||||
)
|
||||
db_session = Mock()
|
||||
db_session.scalar.return_value = conversation
|
||||
cycle_db.add_all([_app(), _conversation()])
|
||||
cycle_db.commit()
|
||||
|
||||
with (
|
||||
_patch_create_session(db_session),
|
||||
patch("core.app.task_pipeline.message_cycle_manager.redis_client") as mock_redis,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.LLMGenerator") as mock_llm_generator,
|
||||
):
|
||||
@@ -350,27 +379,27 @@ class TestMessageCycleManagerOptimization:
|
||||
|
||||
message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", "hello")
|
||||
|
||||
assert cycle_db.in_transaction() is False
|
||||
with Session(sqlite_engine) as verification_session:
|
||||
conversation = verification_session.get(Conversation, "conv-1")
|
||||
assert conversation is not None
|
||||
assert conversation.name == "generated-title"
|
||||
db_session.commit.assert_called_once()
|
||||
mock_redis.setex.assert_called_once()
|
||||
|
||||
def test_generate_conversation_name_worker_falls_back_when_generation_fails(
|
||||
self, message_cycle_manager, caplog: pytest.LogCaptureFixture
|
||||
self,
|
||||
message_cycle_manager,
|
||||
cycle_db: Session,
|
||||
sqlite_engine: Engine,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
):
|
||||
"""Fallback to truncated query when LLM generation fails."""
|
||||
flask_app = Flask(__name__)
|
||||
conversation = SimpleNamespace(
|
||||
mode=AppMode.CHAT,
|
||||
app=SimpleNamespace(tenant_id="tenant-1"),
|
||||
app_id="app-id",
|
||||
name="",
|
||||
)
|
||||
db_session = Mock()
|
||||
db_session.scalar.return_value = conversation
|
||||
cycle_db.add_all([_app(), _conversation()])
|
||||
cycle_db.commit()
|
||||
long_query = "q" * 60
|
||||
|
||||
with (
|
||||
_patch_create_session(db_session),
|
||||
patch("core.app.task_pipeline.message_cycle_manager.redis_client") as mock_redis,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.LLMGenerator") as mock_llm_generator,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.dify_config") as mock_dify_config,
|
||||
@@ -382,8 +411,11 @@ class TestMessageCycleManagerOptimization:
|
||||
with caplog.at_level(logging.ERROR, logger="core.app.task_pipeline.message_cycle_manager"):
|
||||
message_cycle_manager._generate_conversation_name_worker(flask_app, "conv-1", long_query)
|
||||
|
||||
assert cycle_db.in_transaction() is False
|
||||
with Session(sqlite_engine) as verification_session:
|
||||
conversation = verification_session.get(Conversation, "conv-1")
|
||||
assert conversation is not None
|
||||
assert conversation.name == (long_query[:47] + "...")
|
||||
db_session.commit.assert_called_once()
|
||||
assert any(record.levelno == logging.ERROR for record in caplog.records)
|
||||
|
||||
def test_handle_annotation_reply_sets_metadata(self, message_cycle_manager):
|
||||
@@ -454,33 +486,25 @@ class TestMessageCycleManagerOptimization:
|
||||
assert message_cycle_manager._task_state.metadata.retriever_resources[0].position == 1
|
||||
assert message_cycle_manager._task_state.metadata.retriever_resources[1].position == 2
|
||||
|
||||
def test_message_file_to_stream_response_builds_signed_url(self, message_cycle_manager):
|
||||
def test_message_file_to_stream_response_builds_signed_url(self, message_cycle_manager, cycle_db: Session):
|
||||
"""Build a stream response with a signed tool file URL.
|
||||
|
||||
Args: message_cycle_manager with mocked Session/db and sign_tool_file.
|
||||
Args: message_cycle_manager with a persisted MessageFile and mocked sign_tool_file.
|
||||
Returns: MessageStreamResponse with signed url and belongs_to normalized to user.
|
||||
Side effects: Calls sign_tool_file for tool file ids.
|
||||
"""
|
||||
message_cycle_manager._application_generate_entity.task_id = "task-1"
|
||||
|
||||
message_file = SimpleNamespace(
|
||||
id="file-1",
|
||||
type="image",
|
||||
belongs_to=None,
|
||||
url="tool://file.verylongextension",
|
||||
message_id="msg-1",
|
||||
cycle_db.add(
|
||||
_message_file(
|
||||
file_id="file-1",
|
||||
message_id="msg-1",
|
||||
belongs_to=None,
|
||||
url="tool://file.verylongextension",
|
||||
)
|
||||
)
|
||||
cycle_db.commit()
|
||||
|
||||
session = Mock()
|
||||
session.scalar.return_value = message_file
|
||||
|
||||
with (
|
||||
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_cls,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.sign_tool_file") as mock_sign,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.db") as mock_db,
|
||||
):
|
||||
mock_db.engine = Mock()
|
||||
mock_session_cls.return_value.__enter__.return_value = session
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.sign_tool_file") as mock_sign:
|
||||
mock_sign.return_value = "signed-url"
|
||||
|
||||
response = message_cycle_manager.message_file_to_stream_response(SimpleNamespace(message_file_id="file-1"))
|
||||
@@ -514,56 +538,42 @@ class TestMessageCycleManagerOptimization:
|
||||
assert len(message_cycle_manager._task_state.metadata.retriever_resources) == 1
|
||||
assert message_cycle_manager._task_state.metadata.retriever_resources[0].position == 1
|
||||
|
||||
def test_message_file_to_stream_response_uses_http_url_directly(self, message_cycle_manager):
|
||||
def test_message_file_to_stream_response_uses_http_url_directly(self, message_cycle_manager, cycle_db: Session):
|
||||
"""Use original URL when message file URL is already HTTP."""
|
||||
message_cycle_manager._application_generate_entity.task_id = "task-http"
|
||||
message_file = SimpleNamespace(
|
||||
id="file-http",
|
||||
type="image",
|
||||
belongs_to="assistant",
|
||||
url="http://example.com/pic.png",
|
||||
message_id="msg-http",
|
||||
)
|
||||
|
||||
session = Mock()
|
||||
session.scalar.return_value = message_file
|
||||
|
||||
with (
|
||||
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_cls,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.db") as mock_db,
|
||||
):
|
||||
mock_db.engine = Mock()
|
||||
mock_session_cls.return_value.__enter__.return_value = session
|
||||
|
||||
response = message_cycle_manager.message_file_to_stream_response(
|
||||
SimpleNamespace(message_file_id="file-http")
|
||||
cycle_db.add(
|
||||
_message_file(
|
||||
file_id="file-http",
|
||||
message_id="msg-http",
|
||||
belongs_to=MessageFileBelongsTo.ASSISTANT,
|
||||
url="http://example.com/pic.png",
|
||||
)
|
||||
)
|
||||
cycle_db.commit()
|
||||
|
||||
response = message_cycle_manager.message_file_to_stream_response(SimpleNamespace(message_file_id="file-http"))
|
||||
|
||||
assert response is not None
|
||||
assert response.url == "http://example.com/pic.png"
|
||||
assert "msg-http" in message_cycle_manager._message_has_file
|
||||
|
||||
def test_message_file_to_stream_response_defaults_extension_to_bin_without_dot(self, message_cycle_manager):
|
||||
def test_message_file_to_stream_response_defaults_extension_to_bin_without_dot(
|
||||
self, message_cycle_manager, cycle_db: Session
|
||||
):
|
||||
"""Default tool file extension to .bin when URL has no extension part."""
|
||||
message_cycle_manager._application_generate_entity.task_id = "task-bin"
|
||||
message_file = SimpleNamespace(
|
||||
id="file-bin",
|
||||
type="file",
|
||||
belongs_to="assistant",
|
||||
url="tool-file-id",
|
||||
message_id="msg-bin",
|
||||
cycle_db.add(
|
||||
_message_file(
|
||||
file_id="file-bin",
|
||||
message_id="msg-bin",
|
||||
belongs_to=MessageFileBelongsTo.ASSISTANT,
|
||||
url="tool-file-id",
|
||||
file_type=FileType.CUSTOM,
|
||||
)
|
||||
)
|
||||
cycle_db.commit()
|
||||
|
||||
session = Mock()
|
||||
session.scalar.return_value = message_file
|
||||
|
||||
with (
|
||||
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_cls,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.sign_tool_file") as mock_sign,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.db") as mock_db,
|
||||
):
|
||||
mock_db.engine = Mock()
|
||||
mock_session_cls.return_value.__enter__.return_value = session
|
||||
with patch("core.app.task_pipeline.message_cycle_manager.sign_tool_file") as mock_sign:
|
||||
mock_sign.return_value = "signed-bin-url"
|
||||
|
||||
response = message_cycle_manager.message_file_to_stream_response(
|
||||
@@ -574,19 +584,13 @@ class TestMessageCycleManagerOptimization:
|
||||
assert response.url == "signed-bin-url"
|
||||
mock_sign.assert_called_once_with(tool_file_id="tool-file-id", extension=".bin")
|
||||
|
||||
def test_message_file_to_stream_response_returns_none_when_file_missing(self, message_cycle_manager):
|
||||
def test_message_file_to_stream_response_returns_none_when_file_missing(
|
||||
self, message_cycle_manager, cycle_db: Session
|
||||
):
|
||||
"""Return None when message file lookup does not find a record."""
|
||||
session = Mock()
|
||||
session.scalar.return_value = None
|
||||
assert list(cycle_db.scalars(select(MessageFile)).all()) == []
|
||||
|
||||
with (
|
||||
patch("core.app.task_pipeline.message_cycle_manager.Session") as mock_session_cls,
|
||||
patch("core.app.task_pipeline.message_cycle_manager.db") as mock_db,
|
||||
):
|
||||
mock_db.engine = Mock()
|
||||
mock_session_cls.return_value.__enter__.return_value = session
|
||||
|
||||
response = message_cycle_manager.message_file_to_stream_response(SimpleNamespace(message_file_id="missing"))
|
||||
response = message_cycle_manager.message_file_to_stream_response(SimpleNamespace(message_file_id="missing"))
|
||||
|
||||
assert response is None
|
||||
|
||||
|
||||
@@ -1,7 +1,57 @@
|
||||
import sys
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
import core.llm_generator.llm_generator as generator_module
|
||||
from core.llm_generator.llm_generator import LLMGenerator, _parse_string_list
|
||||
from core.model_manager import ModelInstance, ModelManager
|
||||
from core.workflow.generator import tool_catalogue as tool_catalogue_module
|
||||
from core.workflow.generator.tool_catalogue import ToolCatalogueEntry
|
||||
from graphon.model_runtime.entities.llm_entities import LLMResult, LLMUsage
|
||||
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage
|
||||
from models.dataset import Dataset
|
||||
from services.workflow_service import WorkflowService
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dataset_session(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> Session:
|
||||
"""Bind the real SQLite session to the production database extension."""
|
||||
|
||||
monkeypatch.setattr(generator_module.db, "session", sqlite_session)
|
||||
return sqlite_session
|
||||
|
||||
|
||||
def _llm_result(content: str) -> LLMResult:
|
||||
"""Build a real non-streaming LLM response around deterministic test content."""
|
||||
|
||||
return LLMResult(
|
||||
model="test-model",
|
||||
message=AssistantPromptMessage(content=content),
|
||||
usage=LLMUsage.empty_usage(),
|
||||
)
|
||||
|
||||
|
||||
def _model_manager() -> tuple[MagicMock, MagicMock]:
|
||||
"""Build spec-constrained mocks for the model-manager boundary and its default model."""
|
||||
|
||||
model_manager = MagicMock(spec=ModelManager)
|
||||
model_instance = MagicMock(spec=ModelInstance)
|
||||
model_manager.get_default_model_instance.return_value = model_instance
|
||||
return model_manager, model_instance
|
||||
|
||||
|
||||
def _dataset(*, dataset_id: str, tenant_id: str, name: str, created_at: datetime) -> Dataset:
|
||||
return Dataset(
|
||||
id=dataset_id,
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
created_by="account-id",
|
||||
created_at=created_at,
|
||||
)
|
||||
|
||||
|
||||
class TestParseStringList:
|
||||
@@ -34,95 +84,115 @@ class TestParseStringList:
|
||||
class TestGenerateWorkflowInstructionSuggestions:
|
||||
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
|
||||
def test_no_default_model(self, mock_for_tenant):
|
||||
mock_for_tenant.return_value.get_default_model_instance.side_effect = Exception("No model")
|
||||
model_manager, _ = _model_manager()
|
||||
model_manager.get_default_model_instance.side_effect = RuntimeError("no default model")
|
||||
mock_for_tenant.return_value = model_manager
|
||||
|
||||
assert LLMGenerator.generate_workflow_instruction_suggestions("tenant", mode="workflow") == []
|
||||
|
||||
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
|
||||
@patch("core.llm_generator.llm_generator.LLMGenerator._build_suggestion_context")
|
||||
def test_llm_success(self, mock_build_context, mock_for_tenant):
|
||||
mock_build_context.return_value = "context"
|
||||
|
||||
mock_model = MagicMock()
|
||||
mock_model.invoke_llm.return_value = MagicMock()
|
||||
mock_model.invoke_llm.return_value.message.get_text_content.return_value = '["idea 1", "idea 2"]'
|
||||
|
||||
mock_for_tenant.return_value.get_default_model_instance.return_value = mock_model
|
||||
model_manager, model_instance = _model_manager()
|
||||
model_instance.invoke_llm.return_value = _llm_result('["idea 1", "idea 2"]')
|
||||
mock_for_tenant.return_value = model_manager
|
||||
|
||||
result = LLMGenerator.generate_workflow_instruction_suggestions("tenant", mode="workflow")
|
||||
assert result == ["idea 1", "idea 2"]
|
||||
model_instance.invoke_llm.assert_called_once()
|
||||
|
||||
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
|
||||
@patch("core.llm_generator.llm_generator.LLMGenerator._build_suggestion_context")
|
||||
def test_llm_error(self, mock_build_context, mock_for_tenant):
|
||||
mock_build_context.return_value = "context"
|
||||
model_manager, model_instance = _model_manager()
|
||||
model_instance.invoke_llm.side_effect = RuntimeError("API error")
|
||||
mock_for_tenant.return_value = model_manager
|
||||
|
||||
mock_model = MagicMock()
|
||||
mock_model.invoke_llm.side_effect = Exception("API error")
|
||||
|
||||
mock_for_tenant.return_value.get_default_model_instance.return_value = mock_model
|
||||
|
||||
assert LLMGenerator.generate_workflow_instruction_suggestions("tenant", mode="workflow") == []
|
||||
result = LLMGenerator.generate_workflow_instruction_suggestions("tenant", mode="workflow")
|
||||
assert result == []
|
||||
model_instance.invoke_llm.assert_called_once()
|
||||
|
||||
@patch("core.llm_generator.llm_generator.ModelManager.for_tenant")
|
||||
@patch("core.llm_generator.llm_generator.LLMGenerator._build_suggestion_context")
|
||||
def test_llm_bad_output(self, mock_build_context, mock_for_tenant):
|
||||
mock_build_context.return_value = "context"
|
||||
model_manager, model_instance = _model_manager()
|
||||
model_instance.invoke_llm.return_value = _llm_result("Not a list")
|
||||
mock_for_tenant.return_value = model_manager
|
||||
|
||||
mock_model = MagicMock()
|
||||
mock_model.invoke_llm.return_value = MagicMock()
|
||||
mock_model.invoke_llm.return_value.message.get_text_content.return_value = "Not a list"
|
||||
|
||||
mock_for_tenant.return_value.get_default_model_instance.return_value = mock_model
|
||||
|
||||
assert LLMGenerator.generate_workflow_instruction_suggestions("tenant", mode="workflow") == []
|
||||
result = LLMGenerator.generate_workflow_instruction_suggestions("tenant", mode="workflow")
|
||||
assert result == []
|
||||
model_instance.invoke_llm.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Dataset,)], indirect=True)
|
||||
class TestBuildSuggestionContext:
|
||||
@patch("core.llm_generator.llm_generator.db.session.scalars")
|
||||
def test_both_success(self, mock_scalars, monkeypatch):
|
||||
mock_scalars.return_value.all.return_value = ["kb1", "kb2"]
|
||||
def test_both_success(self, dataset_session: Session, monkeypatch: pytest.MonkeyPatch):
|
||||
now = datetime.now()
|
||||
dataset_session.add_all(
|
||||
(
|
||||
_dataset(dataset_id="kb-1", tenant_id="tenant", name="kb1", created_at=now),
|
||||
_dataset(
|
||||
dataset_id="kb-2",
|
||||
tenant_id="tenant",
|
||||
name="kb2",
|
||||
created_at=now - timedelta(seconds=1),
|
||||
),
|
||||
_dataset(dataset_id="other-kb", tenant_id="other", name="private", created_at=now),
|
||||
)
|
||||
)
|
||||
dataset_session.commit()
|
||||
|
||||
# ``_build_suggestion_context`` imports the tool catalogue lazily, so we
|
||||
# stub the module in ``sys.modules``. Use ``monkeypatch.setitem`` so the
|
||||
# ORIGINAL module is RESTORED on teardown — a bare ``del`` would evict it
|
||||
# from sys.modules entirely, after which a sibling test that imported
|
||||
# ``build_tool_catalogue`` at collection time (e.g. test_tool_catalogue)
|
||||
# diverges from a freshly re-imported module and its @patch targets stop
|
||||
# applying, silently breaking it under xdist.
|
||||
mock_tool_catalogue = MagicMock()
|
||||
mock_tool_catalogue.build_tool_catalogue.return_value = "catalog"
|
||||
mock_tool_catalogue.format_tool_catalogue.return_value = "tool1\ntool2"
|
||||
monkeypatch.setitem(sys.modules, "core.workflow.generator.tool_catalogue", mock_tool_catalogue)
|
||||
def build_tool_catalogue(_tenant_id: str) -> list[ToolCatalogueEntry]:
|
||||
return [
|
||||
ToolCatalogueEntry(
|
||||
provider_name="provider",
|
||||
provider_type="builtin",
|
||||
plugin_id="",
|
||||
tool_name="tool1",
|
||||
tool_label="tool1",
|
||||
description="First tool",
|
||||
),
|
||||
ToolCatalogueEntry(
|
||||
provider_name="provider",
|
||||
provider_type="builtin",
|
||||
plugin_id="",
|
||||
tool_name="tool2",
|
||||
tool_label="tool2",
|
||||
description="Second tool",
|
||||
),
|
||||
]
|
||||
|
||||
# Keep the real module and formatter; only isolate provider/plugin discovery.
|
||||
monkeypatch.setattr(tool_catalogue_module, "build_tool_catalogue", build_tool_catalogue)
|
||||
|
||||
result = LLMGenerator._build_suggestion_context("tenant")
|
||||
assert "Knowledge bases:\n- kb1\n- kb2" in result
|
||||
assert "Installed tools:\ntool1\ntool2" in result
|
||||
assert "Installed tools:\n- provider/tool1 — First tool\n- provider/tool2 — Second tool" in result
|
||||
|
||||
@patch("core.llm_generator.llm_generator.db.session.scalars")
|
||||
def test_both_fail(self, mock_scalars, monkeypatch):
|
||||
mock_scalars.side_effect = Exception("DB error")
|
||||
def test_both_fail(self, dataset_session: Session, monkeypatch: pytest.MonkeyPatch):
|
||||
def fail_query(_orm_execute_state: object) -> None:
|
||||
raise SQLAlchemyError("DB error")
|
||||
|
||||
# See ``test_both_success``: restore the original module via monkeypatch
|
||||
# rather than ``del``-ing it, so we don't evict it for sibling tests.
|
||||
mock_tool_catalogue = MagicMock()
|
||||
mock_tool_catalogue.build_tool_catalogue.side_effect = Exception("Tool error")
|
||||
monkeypatch.setitem(sys.modules, "core.workflow.generator.tool_catalogue", mock_tool_catalogue)
|
||||
def fail_tool_catalogue(_tenant_id: str) -> list[ToolCatalogueEntry]:
|
||||
raise RuntimeError("Tool error")
|
||||
|
||||
assert LLMGenerator._build_suggestion_context("tenant") == ""
|
||||
event.listen(dataset_session, "do_orm_execute", fail_query)
|
||||
monkeypatch.setattr(tool_catalogue_module, "build_tool_catalogue", fail_tool_catalogue)
|
||||
|
||||
try:
|
||||
assert LLMGenerator._build_suggestion_context("tenant") == ""
|
||||
finally:
|
||||
event.remove(dataset_session, "do_orm_execute", fail_query)
|
||||
|
||||
|
||||
class TestWorkflowServiceInterface:
|
||||
def test_protocol_methods(self):
|
||||
# Just to cover the 'pass' statements in the Protocol definition
|
||||
def test_real_workflow_service_exposes_protocol_methods(self):
|
||||
from core.llm_generator.llm_generator import WorkflowServiceInterface
|
||||
|
||||
class MockService(WorkflowServiceInterface):
|
||||
def get_draft_workflow(self, app_model, workflow_id=None, *, session):
|
||||
return super().get_draft_workflow(app_model, workflow_id, session=session)
|
||||
service: WorkflowServiceInterface = WorkflowService(sessionmaker())
|
||||
|
||||
def get_node_last_run(self, app_model, workflow, node_id):
|
||||
return super().get_node_last_run(app_model, workflow, node_id)
|
||||
|
||||
service = MockService()
|
||||
service.get_draft_workflow(None, session=None)
|
||||
service.get_node_last_run(None, None, "node")
|
||||
assert callable(service.get_draft_workflow)
|
||||
assert callable(service.get_node_last_run)
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
"""Comprehensive unit tests for core/memory/token_buffer_memory.py"""
|
||||
"""Comprehensive SQLite-backed tests for token-buffer memory."""
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine, event
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.memory import token_buffer_memory as memory_module
|
||||
from core.memory.token_buffer_memory import TokenBufferMemory
|
||||
from graphon.file import FileTransferMethod, FileType
|
||||
from graphon.model_runtime.entities import (
|
||||
AssistantPromptMessage,
|
||||
ImagePromptMessageContent,
|
||||
@@ -13,13 +21,44 @@ from graphon.model_runtime.entities import (
|
||||
TextPromptMessageContent,
|
||||
UserPromptMessage,
|
||||
)
|
||||
from models.model import AppMode
|
||||
from models.base import TypeBase
|
||||
from models.enums import ConversationFromSource, CreatorUserRole, MessageFileBelongsTo
|
||||
from models.model import AppMode, Message, MessageFile
|
||||
from models.workflow import Workflow, WorkflowType
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers / shared fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Database:
|
||||
"""Typed SQLite binding plus executed SQL for query-count assertions."""
|
||||
|
||||
engine: Engine
|
||||
session: Session
|
||||
statements: list[tuple[str, object]]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def database(sqlite_engine: Engine, monkeypatch: pytest.MonkeyPatch) -> Iterator[Database]:
|
||||
TypeBase.metadata.create_all(
|
||||
sqlite_engine,
|
||||
tables=[Message.__table__, MessageFile.__table__, Workflow.__table__],
|
||||
)
|
||||
statements: list[tuple[str, object]] = []
|
||||
|
||||
def record_statement(_connection, _cursor, statement, parameters, _context, _executemany) -> None:
|
||||
statements.append((statement, parameters))
|
||||
|
||||
event.listen(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
with Session(sqlite_engine, expire_on_commit=False) as session:
|
||||
database = Database(engine=sqlite_engine, session=session, statements=statements)
|
||||
monkeypatch.setattr(memory_module, "db", database)
|
||||
yield database
|
||||
event.remove(sqlite_engine, "before_cursor_execute", record_statement)
|
||||
|
||||
|
||||
def _make_conversation(mode: AppMode = AppMode.CHAT) -> MagicMock:
|
||||
"""Return a minimal Conversation mock."""
|
||||
conv = MagicMock()
|
||||
@@ -47,6 +86,73 @@ def _make_message(answer: str = "hello", answer_tokens: int = 5) -> MagicMock:
|
||||
return msg
|
||||
|
||||
|
||||
def _persist_message(
|
||||
database: Database,
|
||||
conversation_id: str,
|
||||
*,
|
||||
query: str = "user query",
|
||||
answer: str = "hello",
|
||||
answer_tokens: int = 5,
|
||||
created_at: datetime | None = None,
|
||||
workflow_run_id: str | None = None,
|
||||
) -> Message:
|
||||
message = Message(
|
||||
id=str(uuid4()),
|
||||
app_id="app-1",
|
||||
conversation_id=conversation_id,
|
||||
_inputs={},
|
||||
query=query,
|
||||
message={},
|
||||
message_unit_price=Decimal(0),
|
||||
answer=answer,
|
||||
answer_tokens=answer_tokens,
|
||||
answer_unit_price=Decimal(0),
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.API,
|
||||
workflow_run_id=workflow_run_id,
|
||||
created_at=created_at or datetime.now(UTC).replace(tzinfo=None),
|
||||
)
|
||||
database.session.add(message)
|
||||
database.session.commit()
|
||||
return message
|
||||
|
||||
|
||||
def _persist_message_file(
|
||||
database: Database,
|
||||
message: Message,
|
||||
*,
|
||||
belongs_to: MessageFileBelongsTo | None,
|
||||
) -> MessageFile:
|
||||
message_file = MessageFile(
|
||||
message_id=message.id,
|
||||
type=FileType.IMAGE,
|
||||
transfer_method=FileTransferMethod.REMOTE_URL,
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by="account-1",
|
||||
belongs_to=belongs_to,
|
||||
url="https://example.com/image.png",
|
||||
)
|
||||
database.session.add(message_file)
|
||||
database.session.commit()
|
||||
return message_file
|
||||
|
||||
|
||||
def _persist_workflow(database: Database, *, workflow_id: str) -> Workflow:
|
||||
workflow = Workflow(
|
||||
id=workflow_id,
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
type=WorkflowType.CHAT,
|
||||
version="1",
|
||||
graph="{}",
|
||||
features="{}",
|
||||
created_by="account-1",
|
||||
)
|
||||
database.session.add(workflow)
|
||||
database.session.commit()
|
||||
return workflow
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Tests for __init__ and workflow_run_repo property
|
||||
# ===========================================================================
|
||||
@@ -61,25 +167,25 @@ class TestInit:
|
||||
assert mem.model_instance is mi
|
||||
assert mem._workflow_run_repo is None
|
||||
|
||||
def test_workflow_run_repo_is_created_lazily(self):
|
||||
def test_workflow_run_repo_is_created_lazily(self, database: Database):
|
||||
conv = _make_conversation()
|
||||
mi = _make_model_instance()
|
||||
mem = TokenBufferMemory(conversation=conv, model_instance=mi)
|
||||
|
||||
mock_repo = MagicMock()
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.sessionmaker") as mock_sm,
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.DifyAPIRepositoryFactory.create_api_workflow_run_repository",
|
||||
return_value=mock_repo,
|
||||
),
|
||||
):
|
||||
mock_db.engine = MagicMock()
|
||||
with patch(
|
||||
"core.memory.token_buffer_memory.DifyAPIRepositoryFactory.create_api_workflow_run_repository",
|
||||
return_value=mock_repo,
|
||||
) as repository_factory:
|
||||
repo = mem.workflow_run_repo
|
||||
assert repo is mock_repo
|
||||
assert mem._workflow_run_repo is mock_repo
|
||||
|
||||
session_factory = repository_factory.call_args.args[0]
|
||||
with session_factory() as session:
|
||||
assert isinstance(session, Session)
|
||||
assert session.get_bind() is database.engine
|
||||
|
||||
def test_workflow_run_repo_cached_after_first_access(self):
|
||||
conv = _make_conversation()
|
||||
mi = _make_model_instance()
|
||||
@@ -410,7 +516,7 @@ class TestBuildPromptMessageWithFiles:
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("mode", [AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
def test_workflow_mode_workflow_not_found_raises(self, mode):
|
||||
def test_workflow_mode_workflow_not_found_raises(self, mode, database: Database):
|
||||
"""Raises ValueError when Workflow lookup returns None."""
|
||||
conv = _make_conversation(mode)
|
||||
conv.app = MagicMock()
|
||||
@@ -422,22 +528,17 @@ class TestBuildPromptMessageWithFiles:
|
||||
mem._workflow_run_repo = MagicMock()
|
||||
mem._workflow_run_repo.get_workflow_run_by_id.return_value = mock_workflow_run
|
||||
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
):
|
||||
mock_db.session.scalar.return_value = None # workflow not found
|
||||
|
||||
with pytest.raises(ValueError, match="Workflow not found"):
|
||||
mem._build_prompt_message_with_files(
|
||||
message_files=[],
|
||||
text_content="text",
|
||||
message=_make_message(),
|
||||
app_record=MagicMock(),
|
||||
is_user_message=True,
|
||||
)
|
||||
with pytest.raises(ValueError, match="Workflow not found"):
|
||||
mem._build_prompt_message_with_files(
|
||||
message_files=[],
|
||||
text_content="text",
|
||||
message=_make_message(),
|
||||
app_record=MagicMock(),
|
||||
is_user_message=True,
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("mode", [AppMode.ADVANCED_CHAT, AppMode.WORKFLOW])
|
||||
def test_workflow_mode_success_no_files_user(self, mode):
|
||||
def test_workflow_mode_success_no_files_user(self, mode, database: Database):
|
||||
"""Happy path: workflow mode, no message files → plain UserPromptMessage."""
|
||||
conv = _make_conversation(mode)
|
||||
conv.app = MagicMock()
|
||||
@@ -445,22 +546,16 @@ class TestBuildPromptMessageWithFiles:
|
||||
mock_workflow_run = MagicMock()
|
||||
mock_workflow_run.workflow_id = str(uuid4())
|
||||
|
||||
mock_workflow = MagicMock()
|
||||
mock_workflow.features_dict = {}
|
||||
workflow = _persist_workflow(database, workflow_id=mock_workflow_run.workflow_id)
|
||||
|
||||
mem = TokenBufferMemory(conversation=conv, model_instance=_make_model_instance())
|
||||
mem._workflow_run_repo = MagicMock()
|
||||
mem._workflow_run_repo.get_workflow_run_by_id.return_value = mock_workflow_run
|
||||
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
|
||||
return_value=None,
|
||||
),
|
||||
with patch(
|
||||
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
|
||||
return_value=None,
|
||||
):
|
||||
mock_db.session.scalar.return_value = mock_workflow
|
||||
|
||||
result = mem._build_prompt_message_with_files(
|
||||
message_files=[],
|
||||
text_content="wf text",
|
||||
@@ -471,6 +566,7 @@ class TestBuildPromptMessageWithFiles:
|
||||
|
||||
assert isinstance(result, UserPromptMessage)
|
||||
assert result.content == "wf text"
|
||||
assert database.session.get(Workflow, workflow.id) is workflow
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Invalid mode
|
||||
@@ -498,417 +594,140 @@ class TestBuildPromptMessageWithFiles:
|
||||
|
||||
|
||||
class TestGetHistoryPromptMessages:
|
||||
"""Tests for get_history_prompt_messages."""
|
||||
"""Tests for persisted history retrieval, file batching, and pruning."""
|
||||
|
||||
def _make_memory(self, mode: AppMode = AppMode.CHAT) -> TokenBufferMemory:
|
||||
conv = _make_conversation(mode)
|
||||
conv.app = MagicMock()
|
||||
return TokenBufferMemory(conversation=conv, model_instance=_make_model_instance())
|
||||
|
||||
def test_returns_empty_when_no_messages(self):
|
||||
def test_returns_empty_when_no_messages(self, database: Database) -> None:
|
||||
assert self._make_memory().get_history_prompt_messages() == []
|
||||
|
||||
def test_skips_newest_message_without_answer(self, database: Database) -> None:
|
||||
mem = self._make_memory()
|
||||
with patch("core.memory.token_buffer_memory.db") as mock_db:
|
||||
mock_db.session.scalars.return_value.all.return_value = []
|
||||
result = mem.get_history_prompt_messages()
|
||||
assert result == []
|
||||
message = _persist_message(database, mem.conversation.id, answer="", answer_tokens=0)
|
||||
|
||||
def test_skips_first_message_without_answer(self):
|
||||
"""The newest message (index 0 after extraction) without answer and tokens==0 is skipped."""
|
||||
assert mem.get_history_prompt_messages() == []
|
||||
assert database.session.get(Message, message.id) is message
|
||||
|
||||
def test_message_with_answer_returns_user_and_assistant_prompts(self, database: Database) -> None:
|
||||
mem = self._make_memory()
|
||||
_persist_message(database, mem.conversation.id, query="My query", answer="My answer", answer_tokens=10)
|
||||
|
||||
msg_no_answer = _make_message(answer="", answer_tokens=0)
|
||||
msg_no_answer.parent_message_id = None # ensures extract_thread_messages returns it
|
||||
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.extract_thread_messages",
|
||||
return_value=[msg_no_answer],
|
||||
),
|
||||
):
|
||||
mock_db.session.scalars.return_value.all.side_effect = [
|
||||
[msg_no_answer], # first call: messages query
|
||||
[], # second call: user files query (never hit, but safe)
|
||||
]
|
||||
result = mem.get_history_prompt_messages()
|
||||
|
||||
assert result == []
|
||||
|
||||
def test_message_with_answer_not_skipped(self):
|
||||
"""A message with a non-empty answer is NOT popped."""
|
||||
mem = self._make_memory()
|
||||
|
||||
msg = _make_message(answer="some answer", answer_tokens=10)
|
||||
msg.parent_message_id = None
|
||||
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.extract_thread_messages",
|
||||
return_value=[msg],
|
||||
),
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
# user files query → empty; assistant files query → empty
|
||||
mock_db.session.scalars.return_value.all.return_value = []
|
||||
result = mem.get_history_prompt_messages()
|
||||
|
||||
assert len(result) == 2 # one user + one assistant
|
||||
|
||||
def test_message_limit_default_is_500(self):
|
||||
"""When message_limit is None the stmt is limited to 500."""
|
||||
mem = self._make_memory()
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch("core.memory.token_buffer_memory.select") as mock_select,
|
||||
patch("core.memory.token_buffer_memory.extract_thread_messages", return_value=[]),
|
||||
):
|
||||
mock_stmt = MagicMock()
|
||||
mock_select.return_value.where.return_value.order_by.return_value = mock_stmt
|
||||
mock_stmt.limit.return_value = mock_stmt
|
||||
mock_db.session.scalars.return_value.all.return_value = []
|
||||
|
||||
mem.get_history_prompt_messages(message_limit=None)
|
||||
mock_stmt.limit.assert_called_with(500)
|
||||
|
||||
def test_message_limit_clipped_to_500(self):
|
||||
"""A message_limit > 500 is clamped to 500."""
|
||||
mem = self._make_memory()
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch("core.memory.token_buffer_memory.select") as mock_select,
|
||||
patch("core.memory.token_buffer_memory.extract_thread_messages", return_value=[]),
|
||||
):
|
||||
mock_stmt = MagicMock()
|
||||
mock_select.return_value.where.return_value.order_by.return_value = mock_stmt
|
||||
mock_stmt.limit.return_value = mock_stmt
|
||||
mock_db.session.scalars.return_value.all.return_value = []
|
||||
|
||||
mem.get_history_prompt_messages(message_limit=9999)
|
||||
mock_stmt.limit.assert_called_with(500)
|
||||
|
||||
def test_message_limit_positive_used(self):
|
||||
"""A positive message_limit < 500 is used as-is."""
|
||||
mem = self._make_memory()
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch("core.memory.token_buffer_memory.select") as mock_select,
|
||||
patch("core.memory.token_buffer_memory.extract_thread_messages", return_value=[]),
|
||||
):
|
||||
mock_stmt = MagicMock()
|
||||
mock_select.return_value.where.return_value.order_by.return_value = mock_stmt
|
||||
mock_stmt.limit.return_value = mock_stmt
|
||||
mock_db.session.scalars.return_value.all.return_value = []
|
||||
|
||||
mem.get_history_prompt_messages(message_limit=10)
|
||||
mock_stmt.limit.assert_called_with(10)
|
||||
|
||||
def test_message_limit_zero_uses_default(self):
|
||||
"""message_limit=0 triggers the else branch → default 500."""
|
||||
mem = self._make_memory()
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch("core.memory.token_buffer_memory.select") as mock_select,
|
||||
patch("core.memory.token_buffer_memory.extract_thread_messages", return_value=[]),
|
||||
):
|
||||
mock_stmt = MagicMock()
|
||||
mock_select.return_value.where.return_value.order_by.return_value = mock_stmt
|
||||
mock_stmt.limit.return_value = mock_stmt
|
||||
mock_db.session.scalars.return_value.all.return_value = []
|
||||
|
||||
mem.get_history_prompt_messages(message_limit=0)
|
||||
mock_stmt.limit.assert_called_with(500)
|
||||
|
||||
def test_user_files_cause_build_with_files_call(self):
|
||||
"""When user_files is non-empty _build_prompt_message_with_files is invoked."""
|
||||
mem = self._make_memory()
|
||||
msg = _make_message()
|
||||
msg.parent_message_id = None
|
||||
|
||||
mock_user_file = MagicMock()
|
||||
mock_user_file.message_id = msg.id # must match so batched grouping keys it to this message
|
||||
mock_user_prompt = UserPromptMessage(content="from build")
|
||||
mock_assistant_prompt = AssistantPromptMessage(content="answer")
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def scalars_side_effect(stmt):
|
||||
r = MagicMock()
|
||||
if call_count["n"] == 0:
|
||||
# messages query
|
||||
r.all.return_value = [msg]
|
||||
elif call_count["n"] == 1:
|
||||
# user files
|
||||
r.all.return_value = [mock_user_file]
|
||||
else:
|
||||
# assistant files
|
||||
r.all.return_value = []
|
||||
call_count["n"] += 1
|
||||
return r
|
||||
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.extract_thread_messages",
|
||||
return_value=[msg],
|
||||
),
|
||||
patch.object(
|
||||
mem,
|
||||
"_build_prompt_message_with_files",
|
||||
side_effect=[mock_user_prompt, mock_assistant_prompt],
|
||||
) as mock_build,
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
mock_db.session.scalars.side_effect = scalars_side_effect
|
||||
result = mem.get_history_prompt_messages()
|
||||
|
||||
assert mock_build.call_count >= 1
|
||||
# First call should be user message
|
||||
first_call_kwargs = mock_build.call_args_list[0][1]
|
||||
assert first_call_kwargs["is_user_message"] is True
|
||||
|
||||
def test_assistant_files_cause_build_with_files_call(self):
|
||||
"""When assistant_files is non-empty, build is called with is_user_message=False."""
|
||||
mem = self._make_memory()
|
||||
msg = _make_message()
|
||||
msg.parent_message_id = None
|
||||
|
||||
mock_assistant_file = MagicMock()
|
||||
mock_assistant_file.message_id = msg.id # must match so batched grouping keys it to this message
|
||||
mock_user_prompt = UserPromptMessage(content="query")
|
||||
mock_assistant_prompt = AssistantPromptMessage(content="built")
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def scalars_side_effect(stmt):
|
||||
r = MagicMock()
|
||||
if call_count["n"] == 0:
|
||||
r.all.return_value = [msg]
|
||||
elif call_count["n"] == 1:
|
||||
r.all.return_value = [] # no user files
|
||||
else:
|
||||
r.all.return_value = [mock_assistant_file]
|
||||
call_count["n"] += 1
|
||||
return r
|
||||
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.extract_thread_messages",
|
||||
return_value=[msg],
|
||||
),
|
||||
patch.object(
|
||||
mem,
|
||||
"_build_prompt_message_with_files",
|
||||
return_value=mock_assistant_prompt,
|
||||
) as mock_build,
|
||||
):
|
||||
mock_db.session.scalars.side_effect = scalars_side_effect
|
||||
result = mem.get_history_prompt_messages()
|
||||
|
||||
mock_build.assert_called_once()
|
||||
call_kwargs = mock_build.call_args[1]
|
||||
assert call_kwargs["is_user_message"] is False
|
||||
|
||||
def test_message_files_loaded_with_constant_query_count(self):
|
||||
"""Regression guard against N+1: message files must be batch-loaded.
|
||||
|
||||
Regardless of the number of messages in the thread, file loading must use a
|
||||
constant number of queries (1 messages query + 2 batched file queries),
|
||||
never 2 queries per message.
|
||||
"""
|
||||
mem = self._make_memory()
|
||||
|
||||
messages = [_make_message() for _ in range(5)]
|
||||
for m in messages:
|
||||
m.parent_message_id = None
|
||||
|
||||
scalars_calls = {"n": 0}
|
||||
|
||||
def scalars_side_effect(stmt):
|
||||
r = MagicMock()
|
||||
# First call returns the thread messages; the batched file queries return none.
|
||||
r.all.return_value = messages if scalars_calls["n"] == 0 else []
|
||||
scalars_calls["n"] += 1
|
||||
return r
|
||||
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch("core.memory.token_buffer_memory.extract_thread_messages", return_value=messages),
|
||||
patch("core.memory.token_buffer_memory.FileUploadConfigManager.convert", return_value=None),
|
||||
):
|
||||
mock_db.session.scalars.side_effect = scalars_side_effect
|
||||
mem.get_history_prompt_messages()
|
||||
|
||||
# 1 (messages) + 2 (batched user/assistant files) = 3, independent of message count.
|
||||
# Before this fix it would have been 1 + 2 * 5 = 11 (an N+1 pattern).
|
||||
assert scalars_calls["n"] == 3
|
||||
|
||||
def test_token_pruning_removes_oldest_messages(self):
|
||||
"""If tokens exceed limit, oldest messages are removed until within limit."""
|
||||
conv = _make_conversation()
|
||||
conv.app = MagicMock()
|
||||
|
||||
# Model returns tokens that decrease only after removing pairs
|
||||
token_values = [3000, 1500] # first call over limit, second within
|
||||
mi = MagicMock()
|
||||
mi.get_llm_num_tokens.side_effect = token_values
|
||||
|
||||
mem = TokenBufferMemory(conversation=conv, model_instance=mi)
|
||||
|
||||
msg = _make_message()
|
||||
msg.parent_message_id = None
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def scalars_side_effect(stmt):
|
||||
r = MagicMock()
|
||||
if call_count["n"] == 0:
|
||||
r.all.return_value = [msg]
|
||||
else:
|
||||
r.all.return_value = []
|
||||
call_count["n"] += 1
|
||||
return r
|
||||
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.extract_thread_messages",
|
||||
return_value=[msg],
|
||||
),
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
mock_db.session.scalars.side_effect = scalars_side_effect
|
||||
result = mem.get_history_prompt_messages(max_token_limit=2000)
|
||||
|
||||
# After pruning, we should have fewer than the 2 initial messages
|
||||
assert len(result) <= 1
|
||||
|
||||
def test_token_pruning_stops_at_single_message(self):
|
||||
"""Pruning stops when only 1 message remains (to prevent empty list)."""
|
||||
conv = _make_conversation()
|
||||
conv.app = MagicMock()
|
||||
|
||||
# Always over limit
|
||||
mi = MagicMock()
|
||||
mi.get_llm_num_tokens.return_value = 99999
|
||||
|
||||
mem = TokenBufferMemory(conversation=conv, model_instance=mi)
|
||||
|
||||
msg = _make_message()
|
||||
msg.parent_message_id = None
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def scalars_side_effect(stmt):
|
||||
r = MagicMock()
|
||||
if call_count["n"] == 0:
|
||||
r.all.return_value = [msg]
|
||||
else:
|
||||
r.all.return_value = []
|
||||
call_count["n"] += 1
|
||||
return r
|
||||
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.extract_thread_messages",
|
||||
return_value=[msg],
|
||||
),
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
mock_db.session.scalars.side_effect = scalars_side_effect
|
||||
result = mem.get_history_prompt_messages(max_token_limit=1)
|
||||
|
||||
# At least 1 message should remain
|
||||
assert len(result) >= 1
|
||||
|
||||
def test_no_pruning_when_within_limit(self):
|
||||
"""When tokens ≤ limit, no pruning occurs."""
|
||||
mem = self._make_memory()
|
||||
mem.model_instance.get_llm_num_tokens.return_value = 50 # well under default 2000
|
||||
|
||||
msg = _make_message()
|
||||
msg.parent_message_id = None
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def scalars_side_effect(stmt):
|
||||
r = MagicMock()
|
||||
if call_count["n"] == 0:
|
||||
r.all.return_value = [msg]
|
||||
else:
|
||||
r.all.return_value = []
|
||||
call_count["n"] += 1
|
||||
return r
|
||||
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.extract_thread_messages",
|
||||
return_value=[msg],
|
||||
),
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
mock_db.session.scalars.side_effect = scalars_side_effect
|
||||
result = mem.get_history_prompt_messages(max_token_limit=2000)
|
||||
|
||||
assert len(result) == 2 # user + assistant
|
||||
|
||||
def test_plain_user_and_assistant_messages_returned(self):
|
||||
"""Without files, plain UserPromptMessage and AssistantPromptMessage appear."""
|
||||
mem = self._make_memory()
|
||||
|
||||
msg = _make_message(answer="My answer")
|
||||
msg.query = "My query"
|
||||
msg.parent_message_id = None
|
||||
|
||||
call_count = {"n": 0}
|
||||
|
||||
def scalars_side_effect(stmt):
|
||||
r = MagicMock()
|
||||
if call_count["n"] == 0:
|
||||
r.all.return_value = [msg]
|
||||
else:
|
||||
r.all.return_value = []
|
||||
call_count["n"] += 1
|
||||
return r
|
||||
|
||||
with (
|
||||
patch("core.memory.token_buffer_memory.db") as mock_db,
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.extract_thread_messages",
|
||||
return_value=[msg],
|
||||
),
|
||||
patch(
|
||||
"core.memory.token_buffer_memory.FileUploadConfigManager.convert",
|
||||
return_value=None,
|
||||
),
|
||||
):
|
||||
mock_db.session.scalars.side_effect = scalars_side_effect
|
||||
result = mem.get_history_prompt_messages()
|
||||
result = mem.get_history_prompt_messages()
|
||||
|
||||
assert len(result) == 2
|
||||
user_msg, ai_msg = result
|
||||
assert isinstance(user_msg, UserPromptMessage)
|
||||
assert user_msg.content == "My query"
|
||||
assert isinstance(ai_msg, AssistantPromptMessage)
|
||||
assert ai_msg.content == "My answer"
|
||||
assert isinstance(result[0], UserPromptMessage)
|
||||
assert result[0].content == "My query"
|
||||
assert isinstance(result[1], AssistantPromptMessage)
|
||||
assert result[1].content == "My answer"
|
||||
|
||||
def test_history_is_conversation_scoped(self, database: Database) -> None:
|
||||
mem = self._make_memory()
|
||||
_persist_message(database, mem.conversation.id, answer="visible")
|
||||
_persist_message(database, "other-conversation", answer="hidden")
|
||||
|
||||
result = mem.get_history_prompt_messages()
|
||||
|
||||
assert [prompt.content for prompt in result] == ["user query", "visible"]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("message_limit", "expected_limit"),
|
||||
[(None, 500), (9999, 500), (10, 10), (0, 500)],
|
||||
)
|
||||
def test_message_limit_is_applied_to_executable_query(
|
||||
self,
|
||||
database: Database,
|
||||
message_limit: int | None,
|
||||
expected_limit: int,
|
||||
) -> None:
|
||||
mem = self._make_memory()
|
||||
before = len(database.statements)
|
||||
|
||||
mem.get_history_prompt_messages(message_limit=message_limit)
|
||||
|
||||
statements = database.statements[before:]
|
||||
assert len(statements) == 1
|
||||
sql, parameters = statements[0]
|
||||
assert "LIMIT" in sql
|
||||
assert expected_limit in parameters
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("belongs_to", "is_user_message"),
|
||||
[
|
||||
(MessageFileBelongsTo.USER, True),
|
||||
(None, True),
|
||||
(MessageFileBelongsTo.ASSISTANT, False),
|
||||
],
|
||||
)
|
||||
def test_message_files_use_persisted_ownership(
|
||||
self,
|
||||
database: Database,
|
||||
belongs_to: MessageFileBelongsTo | None,
|
||||
is_user_message: bool,
|
||||
) -> None:
|
||||
mem = self._make_memory()
|
||||
message = _persist_message(database, mem.conversation.id)
|
||||
message_file = _persist_message_file(database, message, belongs_to=belongs_to)
|
||||
built_prompt = (
|
||||
UserPromptMessage(content="built user")
|
||||
if is_user_message
|
||||
else AssistantPromptMessage(content="built assistant")
|
||||
)
|
||||
|
||||
with patch.object(mem, "_build_prompt_message_with_files", return_value=built_prompt) as build_prompt:
|
||||
result = mem.get_history_prompt_messages()
|
||||
|
||||
build_prompt.assert_called_once()
|
||||
assert build_prompt.call_args.kwargs["message_files"] == [message_file]
|
||||
assert build_prompt.call_args.kwargs["is_user_message"] is is_user_message
|
||||
assert built_prompt in result
|
||||
|
||||
def test_message_files_are_batch_loaded_with_constant_query_count(self, database: Database) -> None:
|
||||
mem = self._make_memory()
|
||||
base_time = datetime.now(UTC).replace(tzinfo=None)
|
||||
messages = [
|
||||
_persist_message(
|
||||
database,
|
||||
mem.conversation.id,
|
||||
query=f"query-{index}",
|
||||
answer=f"answer-{index}",
|
||||
created_at=base_time + timedelta(seconds=index),
|
||||
)
|
||||
for index in range(5)
|
||||
]
|
||||
before = len(database.statements)
|
||||
|
||||
with patch("core.memory.token_buffer_memory.extract_thread_messages", return_value=messages):
|
||||
result = mem.get_history_prompt_messages()
|
||||
|
||||
selects = [sql for sql, _ in database.statements[before:] if sql.lstrip().upper().startswith("SELECT")]
|
||||
assert len(selects) == 3
|
||||
assert len(result) == 10
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("token_values", "max_token_limit", "expected_length"),
|
||||
[
|
||||
([3000, 1500], 2000, 1),
|
||||
([99999, 99999], 1, 1),
|
||||
([50], 2000, 2),
|
||||
],
|
||||
)
|
||||
def test_token_pruning_uses_persisted_history(
|
||||
self,
|
||||
database: Database,
|
||||
token_values: list[int],
|
||||
max_token_limit: int,
|
||||
expected_length: int,
|
||||
) -> None:
|
||||
mem = self._make_memory()
|
||||
mem.model_instance.get_llm_num_tokens.side_effect = token_values
|
||||
_persist_message(database, mem.conversation.id)
|
||||
|
||||
result = mem.get_history_prompt_messages(max_token_limit=max_token_limit)
|
||||
|
||||
assert len(result) == expected_length
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
|
||||
@@ -2,13 +2,24 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import core.moderation.api.api as moderation_module
|
||||
from core.extension.api_based_extension_requestor import APIBasedExtensionPoint
|
||||
from core.moderation.api.api import ApiModeration, ModerationInputParams, ModerationOutputParams
|
||||
from core.moderation.base import ModerationAction, ModerationInputsResult, ModerationOutputsResult
|
||||
from models.api_based_extension import APIBasedExtension
|
||||
|
||||
|
||||
class _DatabaseBinding:
|
||||
"""Expose the real SQLite session used by extension lookup."""
|
||||
|
||||
session: Session
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
|
||||
class TestApiModeration:
|
||||
@pytest.fixture
|
||||
def api_config(self):
|
||||
@@ -165,17 +176,27 @@ class TestApiModeration:
|
||||
with pytest.raises(ValueError, match="API-based Extension not found"):
|
||||
api_moderation._get_config_by_requestor(APIBasedExtensionPoint.APP_MODERATION_INPUT, {})
|
||||
|
||||
@patch("core.moderation.api.api.db.session.scalar")
|
||||
def test_get_api_based_extension(self, mock_scalar):
|
||||
mock_ext = MagicMock(spec=APIBasedExtension)
|
||||
mock_scalar.return_value = mock_ext
|
||||
@pytest.mark.parametrize("sqlite_session", [(APIBasedExtension,)], indirect=True)
|
||||
def test_get_api_based_extension(self, sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
target = APIBasedExtension(
|
||||
tenant_id="tenant-1",
|
||||
name="Target extension",
|
||||
api_endpoint="https://example.com/moderate",
|
||||
api_key="encrypted-key",
|
||||
)
|
||||
target.id = "ext-1"
|
||||
other_tenant = APIBasedExtension(
|
||||
tenant_id="tenant-2",
|
||||
name="Other extension",
|
||||
api_endpoint="https://example.com/other",
|
||||
api_key="other-key",
|
||||
)
|
||||
other_tenant.id = "ext-2"
|
||||
sqlite_session.add_all((target, other_tenant))
|
||||
sqlite_session.commit()
|
||||
monkeypatch.setattr(moderation_module, "db", _DatabaseBinding(sqlite_session))
|
||||
|
||||
result = ApiModeration._get_api_based_extension("tenant-1", "ext-1")
|
||||
|
||||
assert result == mock_ext
|
||||
mock_scalar.assert_called_once()
|
||||
# Verify the call has the correct filters
|
||||
args, kwargs = mock_scalar.call_args
|
||||
stmt = args[0]
|
||||
# We can't easily inspect the statement without complex sqlalchemy tricks,
|
||||
# but calling it is usually enough for unit tests if we mock the result.
|
||||
assert result is target
|
||||
assert ApiModeration._get_api_based_extension("tenant-1", "ext-2") is None
|
||||
|
||||
@@ -7,36 +7,212 @@ Covers:
|
||||
- TraceTask._get_user_id_from_metadata
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import PropertyMock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine, event
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.tools.entities.tool_entities import ApiProviderSchemaType
|
||||
from extensions.ext_database import db
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from models.account import Tenant
|
||||
from models.base import TypeBase
|
||||
from models.model import App, AppMode, IconType
|
||||
from models.provider import Provider, ProviderCredential, ProviderModel, ProviderModelCredential, ProviderType
|
||||
from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider, WorkflowToolProvider
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_db_and_session_patches(scalar_side_effect=None, scalar_return_value=None):
|
||||
"""Return (mock_db, cm, session) ready to patch 'core.ops.ops_trace_manager.db'
|
||||
and 'core.ops.ops_trace_manager.Session'.
|
||||
@pytest.fixture
|
||||
def orm_session(sqlite_engine: Engine) -> Iterator[Session]:
|
||||
models = (
|
||||
App,
|
||||
Tenant,
|
||||
Provider,
|
||||
ProviderCredential,
|
||||
ProviderModel,
|
||||
ProviderModelCredential,
|
||||
BuiltinToolProvider,
|
||||
ApiToolProvider,
|
||||
WorkflowToolProvider,
|
||||
MCPToolProvider,
|
||||
)
|
||||
tables = [model.metadata.tables[model.__tablename__] for model in models]
|
||||
TypeBase.metadata.create_all(sqlite_engine, tables=tables)
|
||||
|
||||
Provide either scalar_side_effect (list, for multiple calls) or
|
||||
scalar_return_value (single value).
|
||||
"""
|
||||
mock_db = MagicMock()
|
||||
mock_db.engine = MagicMock()
|
||||
with patch.object(type(db), "engine", new_callable=PropertyMock, return_value=sqlite_engine):
|
||||
with Session(sqlite_engine, expire_on_commit=False) as session:
|
||||
yield session
|
||||
|
||||
session = MagicMock()
|
||||
if scalar_side_effect is not None:
|
||||
session.scalar.side_effect = scalar_side_effect
|
||||
|
||||
def _persist_app(session: Session, *, tenant_id: str, name: str = "MyApp") -> App:
|
||||
app = App(
|
||||
id=str(uuid.uuid4()),
|
||||
tenant_id=tenant_id,
|
||||
name=name,
|
||||
mode=AppMode.WORKFLOW,
|
||||
icon_type=IconType.EMOJI,
|
||||
icon="workflow",
|
||||
icon_background="#FFFFFF",
|
||||
enable_site=True,
|
||||
enable_api=False,
|
||||
)
|
||||
session.add(app)
|
||||
session.commit()
|
||||
return app
|
||||
|
||||
|
||||
def _persist_tenant(session: Session, *, name: str = "MyWorkspace") -> Tenant:
|
||||
tenant = Tenant(name=name)
|
||||
session.add(tenant)
|
||||
session.commit()
|
||||
return tenant
|
||||
|
||||
|
||||
def _persist_tool_provider(
|
||||
session: Session, provider_type: str
|
||||
) -> BuiltinToolProvider | ApiToolProvider | WorkflowToolProvider | MCPToolProvider:
|
||||
tenant_id = str(uuid.uuid4())
|
||||
user_id = str(uuid.uuid4())
|
||||
if provider_type in {"builtin", "plugin"}:
|
||||
provider = BuiltinToolProvider(
|
||||
name="CredentialA",
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
provider="test/provider",
|
||||
)
|
||||
elif provider_type == "api":
|
||||
provider = ApiToolProvider(
|
||||
name="CredentialA",
|
||||
icon="icon.svg",
|
||||
schema="{}",
|
||||
schema_type_str=ApiProviderSchemaType.OPENAPI,
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
description="API provider",
|
||||
tools_str="[]",
|
||||
credentials_str="{}",
|
||||
)
|
||||
elif provider_type == "workflow":
|
||||
provider = WorkflowToolProvider(
|
||||
name="CredentialA",
|
||||
label="CredentialA",
|
||||
icon="icon.svg",
|
||||
app_id=str(uuid.uuid4()),
|
||||
version="1",
|
||||
user_id=user_id,
|
||||
tenant_id=tenant_id,
|
||||
description="Workflow provider",
|
||||
)
|
||||
elif provider_type == "mcp":
|
||||
provider = MCPToolProvider(
|
||||
name="CredentialA",
|
||||
server_identifier="credential-a",
|
||||
server_url="https://example.com/mcp",
|
||||
server_url_hash="credential-a-hash",
|
||||
icon="icon.svg",
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
)
|
||||
else:
|
||||
session.scalar.return_value = scalar_return_value
|
||||
raise ValueError(f"unsupported provider type: {provider_type}")
|
||||
|
||||
cm = MagicMock()
|
||||
cm.__enter__ = MagicMock(return_value=session)
|
||||
cm.__exit__ = MagicMock(return_value=False)
|
||||
session.add(provider)
|
||||
session.commit()
|
||||
return provider
|
||||
|
||||
return mock_db, cm, session
|
||||
|
||||
def _persist_provider_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
credential_name: str = "ProvCredName",
|
||||
) -> ProviderCredential:
|
||||
credential = ProviderCredential(
|
||||
tenant_id=tenant_id,
|
||||
provider_name="openai",
|
||||
credential_name=credential_name,
|
||||
encrypted_config="{}",
|
||||
)
|
||||
session.add(credential)
|
||||
session.commit()
|
||||
return credential
|
||||
|
||||
|
||||
def _persist_model_credential(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
credential_name: str = "ModelCredName",
|
||||
) -> ProviderModelCredential:
|
||||
credential = ProviderModelCredential(
|
||||
tenant_id=tenant_id,
|
||||
provider_name="openai",
|
||||
model_name="gpt-4",
|
||||
model_type=ModelType.LLM,
|
||||
credential_name=credential_name,
|
||||
encrypted_config="{}",
|
||||
)
|
||||
session.add(credential)
|
||||
session.commit()
|
||||
return credential
|
||||
|
||||
|
||||
def _persist_provider(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
credential_id: str | None,
|
||||
) -> Provider:
|
||||
provider = Provider(
|
||||
tenant_id=tenant_id,
|
||||
provider_name="openai",
|
||||
provider_type=ProviderType.CUSTOM,
|
||||
credential_id=credential_id,
|
||||
)
|
||||
session.add(provider)
|
||||
session.commit()
|
||||
return provider
|
||||
|
||||
|
||||
def _persist_provider_model(
|
||||
session: Session,
|
||||
*,
|
||||
tenant_id: str,
|
||||
credential_id: str | None,
|
||||
) -> ProviderModel:
|
||||
model = ProviderModel(
|
||||
tenant_id=tenant_id,
|
||||
provider_name="openai",
|
||||
model_name="gpt-4",
|
||||
model_type=ModelType.LLM,
|
||||
credential_id=credential_id,
|
||||
)
|
||||
session.add(model)
|
||||
session.commit()
|
||||
return model
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _raise_on_table(engine: Engine, table_name: str) -> Iterator[None]:
|
||||
"""Raise only when SQL targets the named table, leaving other real lookups intact."""
|
||||
|
||||
def fail_target_query(_conn, _cursor, statement, _parameters, _context, _executemany):
|
||||
if f"FROM {table_name}" in statement:
|
||||
raise RuntimeError(f"forced failure for {table_name}")
|
||||
|
||||
event.listen(engine, "before_cursor_execute", fail_target_query)
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
event.remove(engine, "before_cursor_execute", fail_target_query)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -47,62 +223,42 @@ def _make_db_and_session_patches(scalar_side_effect=None, scalar_return_value=No
|
||||
class TestLookupAppAndWorkspaceNames:
|
||||
"""Tests for _lookup_app_and_workspace_names(app_id, tenant_id)."""
|
||||
|
||||
def test_both_found(self):
|
||||
def test_both_found(self, orm_session: Session):
|
||||
"""Returns (app_name, workspace_name) when both records exist."""
|
||||
from core.ops.ops_trace_manager import _lookup_app_and_workspace_names
|
||||
|
||||
mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=["MyApp", "MyWorkspace"])
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names("app-123", "tenant-456")
|
||||
tenant = _persist_tenant(orm_session)
|
||||
app = _persist_app(orm_session, tenant_id=tenant.id)
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names(app.id, tenant.id)
|
||||
|
||||
assert app_name == "MyApp"
|
||||
assert workspace_name == "MyWorkspace"
|
||||
|
||||
def test_app_only_found(self):
|
||||
def test_app_only_found(self, orm_session: Session):
|
||||
"""Returns (app_name, '') when tenant record is absent."""
|
||||
from core.ops.ops_trace_manager import _lookup_app_and_workspace_names
|
||||
|
||||
mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=["MyApp", None])
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names("app-123", "tenant-456")
|
||||
app = _persist_app(orm_session, tenant_id=str(uuid.uuid4()))
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names(app.id, str(uuid.uuid4()))
|
||||
|
||||
assert app_name == "MyApp"
|
||||
assert workspace_name == ""
|
||||
|
||||
def test_tenant_only_found(self):
|
||||
def test_tenant_only_found(self, orm_session: Session):
|
||||
"""Returns ('', workspace_name) when app record is absent."""
|
||||
from core.ops.ops_trace_manager import _lookup_app_and_workspace_names
|
||||
|
||||
mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=[None, "MyWorkspace"])
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names("app-123", "tenant-456")
|
||||
tenant = _persist_tenant(orm_session)
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names(str(uuid.uuid4()), tenant.id)
|
||||
|
||||
assert app_name == ""
|
||||
assert workspace_name == "MyWorkspace"
|
||||
|
||||
def test_neither_found(self):
|
||||
def test_neither_found(self, orm_session: Session):
|
||||
"""Returns ('', '') when both DB lookups return None."""
|
||||
from core.ops.ops_trace_manager import _lookup_app_and_workspace_names
|
||||
|
||||
mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=[None, None])
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names("app-123", "tenant-456")
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names(str(uuid.uuid4()), str(uuid.uuid4()))
|
||||
|
||||
assert app_name == ""
|
||||
assert workspace_name == ""
|
||||
@@ -111,50 +267,30 @@ class TestLookupAppAndWorkspaceNames:
|
||||
"""Returns ('', '') immediately when both IDs are None — no DB access."""
|
||||
from core.ops.ops_trace_manager import _lookup_app_and_workspace_names
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_cls = MagicMock()
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names(None, None)
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", mock_session_cls),
|
||||
):
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names(None, None)
|
||||
|
||||
mock_session_cls.assert_not_called()
|
||||
assert app_name == ""
|
||||
assert workspace_name == ""
|
||||
|
||||
def test_app_id_none_only_queries_tenant(self):
|
||||
def test_app_id_none_only_queries_tenant(self, orm_session: Session):
|
||||
"""When app_id is None, only the tenant query is issued."""
|
||||
from core.ops.ops_trace_manager import _lookup_app_and_workspace_names
|
||||
|
||||
mock_db, cm, session = _make_db_and_session_patches(scalar_return_value="OnlyWorkspace")
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names(None, "tenant-456")
|
||||
tenant = _persist_tenant(orm_session, name="OnlyWorkspace")
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names(None, tenant.id)
|
||||
|
||||
assert app_name == ""
|
||||
assert workspace_name == "OnlyWorkspace"
|
||||
assert session.scalar.call_count == 1
|
||||
|
||||
def test_tenant_id_none_only_queries_app(self):
|
||||
def test_tenant_id_none_only_queries_app(self, orm_session: Session):
|
||||
"""When tenant_id is None, only the app query is issued."""
|
||||
from core.ops.ops_trace_manager import _lookup_app_and_workspace_names
|
||||
|
||||
mock_db, cm, session = _make_db_and_session_patches(scalar_return_value="OnlyApp")
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names("app-123", None)
|
||||
app = _persist_app(orm_session, tenant_id=str(uuid.uuid4()), name="OnlyApp")
|
||||
app_name, workspace_name = _lookup_app_and_workspace_names(app.id, None)
|
||||
|
||||
assert app_name == "OnlyApp"
|
||||
assert workspace_name == ""
|
||||
assert session.scalar.call_count == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -166,32 +302,20 @@ class TestLookupCredentialName:
|
||||
"""Tests for _lookup_credential_name(credential_id, provider_type)."""
|
||||
|
||||
@pytest.mark.parametrize("provider_type", ["builtin", "plugin", "api", "workflow", "mcp"])
|
||||
def test_known_provider_types_return_name(self, provider_type):
|
||||
def test_known_provider_types_return_name(self, provider_type: str, orm_session: Session):
|
||||
"""Each valid provider_type results in a DB query and returns the credential name."""
|
||||
from core.ops.ops_trace_manager import _lookup_credential_name
|
||||
|
||||
mock_db, cm, session = _make_db_and_session_patches(scalar_return_value="CredentialA")
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
result = _lookup_credential_name("cred-123", provider_type)
|
||||
provider = _persist_tool_provider(orm_session, provider_type)
|
||||
result = _lookup_credential_name(provider.id, provider_type)
|
||||
|
||||
assert result == "CredentialA"
|
||||
session.scalar.assert_called_once()
|
||||
|
||||
def test_credential_not_found_returns_empty_string(self):
|
||||
def test_credential_not_found_returns_empty_string(self, orm_session: Session):
|
||||
"""Returns '' when DB yields None for the given credential_id."""
|
||||
from core.ops.ops_trace_manager import _lookup_credential_name
|
||||
|
||||
mock_db, cm, _session = _make_db_and_session_patches(scalar_return_value=None)
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
result = _lookup_credential_name("cred-999", "api")
|
||||
result = _lookup_credential_name(str(uuid.uuid4()), "api")
|
||||
|
||||
assert result == ""
|
||||
|
||||
@@ -199,48 +323,24 @@ class TestLookupCredentialName:
|
||||
"""Returns '' immediately for an unrecognised provider_type — no DB access."""
|
||||
from core.ops.ops_trace_manager import _lookup_credential_name
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_cls = MagicMock()
|
||||
result = _lookup_credential_name(str(uuid.uuid4()), "unknown_type")
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", mock_session_cls),
|
||||
):
|
||||
result = _lookup_credential_name("cred-123", "unknown_type")
|
||||
|
||||
mock_session_cls.assert_not_called()
|
||||
assert result == ""
|
||||
|
||||
def test_none_credential_id_returns_empty_string_without_db(self):
|
||||
"""Returns '' immediately when credential_id is None — no DB access."""
|
||||
from core.ops.ops_trace_manager import _lookup_credential_name
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_cls = MagicMock()
|
||||
result = _lookup_credential_name(None, "api")
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", mock_session_cls),
|
||||
):
|
||||
result = _lookup_credential_name(None, "api")
|
||||
|
||||
mock_session_cls.assert_not_called()
|
||||
assert result == ""
|
||||
|
||||
def test_none_provider_type_returns_empty_string_without_db(self):
|
||||
"""Returns '' immediately when provider_type is None — no DB access."""
|
||||
from core.ops.ops_trace_manager import _lookup_credential_name
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_cls = MagicMock()
|
||||
result = _lookup_credential_name(str(uuid.uuid4()), None)
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", mock_session_cls),
|
||||
):
|
||||
result = _lookup_credential_name("cred-123", None)
|
||||
|
||||
mock_session_cls.assert_not_called()
|
||||
assert result == ""
|
||||
|
||||
def test_builtin_and_plugin_map_to_same_model(self):
|
||||
@@ -281,106 +381,78 @@ class TestLookupCredentialName:
|
||||
class TestLookupLlmCredentialInfo:
|
||||
"""Tests for _lookup_llm_credential_info(tenant_id, provider, model, model_type)."""
|
||||
|
||||
def _provider_record(self, credential_id: str | None = None) -> MagicMock:
|
||||
record = MagicMock()
|
||||
record.credential_id = credential_id
|
||||
return record
|
||||
|
||||
def _model_record(self, credential_id: str | None = None) -> MagicMock:
|
||||
record = MagicMock()
|
||||
record.credential_id = credential_id
|
||||
return record
|
||||
|
||||
def test_model_level_credential_found(self):
|
||||
def test_model_level_credential_found(self, orm_session: Session):
|
||||
"""Returns model-level credential_id and name when ProviderModel has a credential."""
|
||||
from core.ops.ops_trace_manager import _lookup_llm_credential_info
|
||||
|
||||
provider_record = self._provider_record(credential_id=None)
|
||||
model_record = self._model_record(credential_id="model-cred-id")
|
||||
tenant_id = str(uuid.uuid4())
|
||||
model_credential = _persist_model_credential(orm_session, tenant_id=tenant_id)
|
||||
_persist_provider(orm_session, tenant_id=tenant_id, credential_id=None)
|
||||
_persist_provider_model(orm_session, tenant_id=tenant_id, credential_id=model_credential.id)
|
||||
|
||||
# scalar calls: (1) Provider, (2) ProviderModel, (3) ProviderModelCredential.credential_name
|
||||
mock_db, cm, _session = _make_db_and_session_patches(
|
||||
scalar_side_effect=[provider_record, model_record, "ModelCredName"]
|
||||
decoy_tenant_id = str(uuid.uuid4())
|
||||
decoy_credential = _persist_model_credential(
|
||||
orm_session,
|
||||
tenant_id=decoy_tenant_id,
|
||||
credential_name="WrongTenantCredential",
|
||||
)
|
||||
_persist_provider(orm_session, tenant_id=decoy_tenant_id, credential_id=None)
|
||||
_persist_provider_model(orm_session, tenant_id=decoy_tenant_id, credential_id=decoy_credential.id)
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4")
|
||||
cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4")
|
||||
|
||||
assert cred_id == "model-cred-id"
|
||||
assert cred_id == model_credential.id
|
||||
assert cred_name == "ModelCredName"
|
||||
|
||||
def test_provider_level_fallback_when_no_model_credential(self):
|
||||
def test_provider_level_fallback_when_no_model_credential(self, orm_session: Session):
|
||||
"""Falls back to provider-level credential when ProviderModel has no credential_id."""
|
||||
from core.ops.ops_trace_manager import _lookup_llm_credential_info
|
||||
|
||||
provider_record = self._provider_record(credential_id="prov-cred-id")
|
||||
model_record = self._model_record(credential_id=None)
|
||||
tenant_id = str(uuid.uuid4())
|
||||
provider_credential = _persist_provider_credential(orm_session, tenant_id=tenant_id)
|
||||
_persist_provider(orm_session, tenant_id=tenant_id, credential_id=provider_credential.id)
|
||||
_persist_provider_model(orm_session, tenant_id=tenant_id, credential_id=None)
|
||||
|
||||
# scalar calls: (1) Provider, (2) ProviderModel (no cred), (3) ProviderCredential.credential_name
|
||||
mock_db, cm, _session = _make_db_and_session_patches(
|
||||
scalar_side_effect=[provider_record, model_record, "ProvCredName"]
|
||||
)
|
||||
cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4")
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4")
|
||||
|
||||
assert cred_id == "prov-cred-id"
|
||||
assert cred_id == provider_credential.id
|
||||
assert cred_name == "ProvCredName"
|
||||
|
||||
def test_provider_level_fallback_when_no_model_record(self):
|
||||
def test_provider_level_fallback_when_no_model_record(self, orm_session: Session):
|
||||
"""Falls back to provider-level credential when no ProviderModel row exists."""
|
||||
from core.ops.ops_trace_manager import _lookup_llm_credential_info
|
||||
|
||||
provider_record = self._provider_record(credential_id="prov-cred-id")
|
||||
tenant_id = str(uuid.uuid4())
|
||||
provider_credential = _persist_provider_credential(orm_session, tenant_id=tenant_id)
|
||||
_persist_provider(orm_session, tenant_id=tenant_id, credential_id=provider_credential.id)
|
||||
|
||||
# scalar calls: (1) Provider, (2) ProviderModel → None, (3) ProviderCredential.credential_name
|
||||
mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=[provider_record, None, "ProvCredName"])
|
||||
cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4")
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4")
|
||||
|
||||
assert cred_id == "prov-cred-id"
|
||||
assert cred_id == provider_credential.id
|
||||
assert cred_name == "ProvCredName"
|
||||
|
||||
def test_no_model_arg_uses_provider_level_only(self):
|
||||
def test_no_model_arg_uses_provider_level_only(self, orm_session: Session):
|
||||
"""When model is None, skips ProviderModel query and uses provider credential."""
|
||||
from core.ops.ops_trace_manager import _lookup_llm_credential_info
|
||||
|
||||
provider_record = self._provider_record(credential_id="prov-cred-id")
|
||||
tenant_id = str(uuid.uuid4())
|
||||
provider_credential = _persist_provider_credential(orm_session, tenant_id=tenant_id)
|
||||
_persist_provider(orm_session, tenant_id=tenant_id, credential_id=provider_credential.id)
|
||||
|
||||
# scalar calls: (1) Provider, (2) ProviderCredential.credential_name — no ProviderModel
|
||||
mock_db, cm, session = _make_db_and_session_patches(scalar_side_effect=[provider_record, "ProvCredName"])
|
||||
cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", None)
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", None)
|
||||
|
||||
assert cred_id == "prov-cred-id"
|
||||
assert cred_id == provider_credential.id
|
||||
assert cred_name == "ProvCredName"
|
||||
assert session.scalar.call_count == 2
|
||||
|
||||
def test_provider_not_found_returns_none_and_empty(self):
|
||||
def test_provider_not_found_returns_none_and_empty(self, orm_session: Session):
|
||||
"""Returns (None, '') when Provider record does not exist."""
|
||||
from core.ops.ops_trace_manager import _lookup_llm_credential_info
|
||||
|
||||
mock_db, cm, _session = _make_db_and_session_patches(scalar_return_value=None)
|
||||
other_tenant_id = str(uuid.uuid4())
|
||||
_persist_provider(orm_session, tenant_id=other_tenant_id, credential_id=None)
|
||||
tenant_id = str(uuid.uuid4())
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4")
|
||||
cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4")
|
||||
|
||||
assert cred_id is None
|
||||
assert cred_name == ""
|
||||
@@ -389,16 +461,8 @@ class TestLookupLlmCredentialInfo:
|
||||
"""Returns (None, '') immediately when tenant_id is None — no DB access."""
|
||||
from core.ops.ops_trace_manager import _lookup_llm_credential_info
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_cls = MagicMock()
|
||||
cred_id, cred_name = _lookup_llm_credential_info(None, "openai", "gpt-4")
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", mock_session_cls),
|
||||
):
|
||||
cred_id, cred_name = _lookup_llm_credential_info(None, "openai", "gpt-4")
|
||||
|
||||
mock_session_cls.assert_not_called()
|
||||
assert cred_id is None
|
||||
assert cred_name == ""
|
||||
|
||||
@@ -406,69 +470,46 @@ class TestLookupLlmCredentialInfo:
|
||||
"""Returns (None, '') immediately when provider is None — no DB access."""
|
||||
from core.ops.ops_trace_manager import _lookup_llm_credential_info
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_session_cls = MagicMock()
|
||||
cred_id, cred_name = _lookup_llm_credential_info(str(uuid.uuid4()), None, "gpt-4")
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", mock_session_cls),
|
||||
):
|
||||
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", None, "gpt-4")
|
||||
|
||||
mock_session_cls.assert_not_called()
|
||||
assert cred_id is None
|
||||
assert cred_name == ""
|
||||
|
||||
def test_db_error_on_outer_query_returns_none_and_empty(self):
|
||||
def test_db_error_on_outer_query_returns_none_and_empty(self, orm_session: Session, sqlite_engine: Engine):
|
||||
"""Returns (None, '') and logs a warning when the outer DB query raises."""
|
||||
from core.ops.ops_trace_manager import _lookup_llm_credential_info
|
||||
|
||||
mock_db, cm, session = _make_db_and_session_patches()
|
||||
session.scalar.side_effect = Exception("DB connection failed")
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4")
|
||||
with _raise_on_table(sqlite_engine, "providers"):
|
||||
cred_id, cred_name = _lookup_llm_credential_info(str(uuid.uuid4()), "openai", "gpt-4")
|
||||
|
||||
assert cred_id is None
|
||||
assert cred_name == ""
|
||||
|
||||
def test_credential_name_lookup_failure_returns_id_with_empty_name(self):
|
||||
def test_credential_name_lookup_failure_returns_id_with_empty_name(
|
||||
self, orm_session: Session, sqlite_engine: Engine
|
||||
):
|
||||
"""When credential name sub-query fails, returns cred_id but '' for name."""
|
||||
from core.ops.ops_trace_manager import _lookup_llm_credential_info
|
||||
|
||||
provider_record = self._provider_record(credential_id="prov-cred-id")
|
||||
tenant_id = str(uuid.uuid4())
|
||||
provider_credential = _persist_provider_credential(orm_session, tenant_id=tenant_id)
|
||||
_persist_provider(orm_session, tenant_id=tenant_id, credential_id=provider_credential.id)
|
||||
|
||||
# Provider found, no model record, then name lookup raises
|
||||
mock_db, cm, _session = _make_db_and_session_patches(
|
||||
scalar_side_effect=[provider_record, None, Exception("deleted")]
|
||||
)
|
||||
with _raise_on_table(sqlite_engine, "provider_credentials"):
|
||||
cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4")
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4")
|
||||
|
||||
assert cred_id == "prov-cred-id"
|
||||
assert cred_id == provider_credential.id
|
||||
assert cred_name == ""
|
||||
|
||||
def test_no_credential_on_provider_or_model_returns_none_id(self):
|
||||
def test_no_credential_on_provider_or_model_returns_none_id(self, orm_session: Session):
|
||||
"""Returns (None, '') when neither provider nor model has a credential_id."""
|
||||
from core.ops.ops_trace_manager import _lookup_llm_credential_info
|
||||
|
||||
provider_record = self._provider_record(credential_id=None)
|
||||
model_record = self._model_record(credential_id=None)
|
||||
tenant_id = str(uuid.uuid4())
|
||||
_persist_provider(orm_session, tenant_id=tenant_id, credential_id=None)
|
||||
_persist_provider_model(orm_session, tenant_id=tenant_id, credential_id=None)
|
||||
|
||||
mock_db, cm, _session = _make_db_and_session_patches(scalar_side_effect=[provider_record, model_record])
|
||||
|
||||
with (
|
||||
patch("core.ops.ops_trace_manager.db", mock_db),
|
||||
patch("core.ops.ops_trace_manager.Session", return_value=cm),
|
||||
):
|
||||
cred_id, cred_name = _lookup_llm_credential_info("tenant-1", "openai", "gpt-4")
|
||||
cred_id, cred_name = _lookup_llm_credential_info(tenant_id, "openai", "gpt-4")
|
||||
|
||||
assert cred_id is None
|
||||
assert cred_name == ""
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import re
|
||||
from datetime import datetime
|
||||
from unittest.mock import MagicMock, patch
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
import core.ops.utils as utils_module
|
||||
from core.ops.utils import (
|
||||
filter_none_values,
|
||||
generate_dotted_order,
|
||||
@@ -15,6 +17,42 @@ from core.ops.utils import (
|
||||
validate_url,
|
||||
validate_url_with_path,
|
||||
)
|
||||
from models.enums import ConversationFromSource
|
||||
from models.model import Message
|
||||
|
||||
|
||||
class _DatabaseBinding:
|
||||
"""Expose the real SQLite session used by the message lookup helper."""
|
||||
|
||||
session: Session
|
||||
|
||||
def __init__(self, session: Session) -> None:
|
||||
self.session = session
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def message_session(sqlite_session: Session, monkeypatch: pytest.MonkeyPatch) -> Session:
|
||||
"""Bind the message lookup helper to the shared SQLite test session."""
|
||||
|
||||
monkeypatch.setattr(utils_module, "db", _DatabaseBinding(sqlite_session))
|
||||
return sqlite_session
|
||||
|
||||
|
||||
def _message(message_id: str) -> Message:
|
||||
message = Message(
|
||||
id=message_id,
|
||||
app_id="app-id",
|
||||
conversation_id="conversation-id",
|
||||
query="question",
|
||||
message={"role": "user", "content": "question"},
|
||||
answer="answer",
|
||||
message_unit_price=Decimal("0.0001"),
|
||||
answer_unit_price=Decimal("0.0001"),
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.API,
|
||||
)
|
||||
message._inputs = {}
|
||||
return message
|
||||
|
||||
|
||||
class TestValidateUrl:
|
||||
@@ -220,22 +258,20 @@ class TestFilterNoneValues:
|
||||
assert filter_none_values({}) == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Message,)], indirect=True)
|
||||
class TestGetMessageData:
|
||||
"""Test cases for get_message_data function"""
|
||||
|
||||
@patch("core.ops.utils.db")
|
||||
@patch("core.ops.utils.Message")
|
||||
@patch("core.ops.utils.select")
|
||||
def test_get_message_data(self, mock_select, mock_message, mock_db):
|
||||
mock_scalar = mock_db.session.scalar
|
||||
mock_msg_instance = MagicMock()
|
||||
mock_scalar.return_value = mock_msg_instance
|
||||
def test_get_message_data(self, message_session: Session):
|
||||
target = _message("message-id")
|
||||
unrelated = _message("other-message-id")
|
||||
message_session.add_all((target, unrelated))
|
||||
message_session.commit()
|
||||
|
||||
result = get_message_data("message-id")
|
||||
|
||||
assert result == mock_msg_instance
|
||||
mock_select.assert_called_once()
|
||||
mock_scalar.assert_called_once()
|
||||
assert result is target
|
||||
assert result.id == "message-id"
|
||||
|
||||
|
||||
class TestMeasureTime:
|
||||
|
||||
@@ -1,9 +1,42 @@
|
||||
from unittest.mock import MagicMock
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from constants import UUID_NIL
|
||||
from core.prompt.utils.extract_thread_messages import extract_thread_messages
|
||||
from core.prompt.utils.get_thread_messages_length import get_thread_messages_length
|
||||
from models.enums import ConversationFromSource
|
||||
from models.model import Message
|
||||
|
||||
|
||||
def _persisted_message(
|
||||
*,
|
||||
message_id: str,
|
||||
conversation_id: str,
|
||||
parent_message_id: str,
|
||||
answer: str,
|
||||
created_at: datetime,
|
||||
) -> Message:
|
||||
message = Message(
|
||||
id=message_id,
|
||||
app_id="app-id",
|
||||
conversation_id=conversation_id,
|
||||
query="question",
|
||||
message={"role": "user", "content": "question"},
|
||||
answer=answer,
|
||||
message_unit_price=Decimal("0.0001"),
|
||||
answer_unit_price=Decimal("0.0001"),
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.API,
|
||||
parent_message_id=parent_message_id,
|
||||
created_at=created_at,
|
||||
updated_at=created_at,
|
||||
)
|
||||
message._inputs = {}
|
||||
return message
|
||||
|
||||
|
||||
class MockMessage:
|
||||
@@ -104,33 +137,64 @@ def test_extract_thread_messages_breaks_when_parent_is_none():
|
||||
assert result[0].id == id2
|
||||
|
||||
|
||||
def test_get_thread_messages_length_excludes_newly_created_empty_answer():
|
||||
@pytest.mark.parametrize("sqlite_session", [(Message,)], indirect=True)
|
||||
def test_get_thread_messages_length_excludes_newly_created_empty_answer(sqlite_session: Session):
|
||||
id1, id2 = str(uuid4()), str(uuid4())
|
||||
now = datetime.now()
|
||||
messages = [
|
||||
MockMessage(id2, id1, answer=""), # newest generated message should be excluded
|
||||
MockMessage(id1, UUID_NIL, answer="ok"),
|
||||
_persisted_message(
|
||||
message_id=id2,
|
||||
conversation_id="conversation-1",
|
||||
parent_message_id=id1,
|
||||
answer="",
|
||||
created_at=now,
|
||||
),
|
||||
_persisted_message(
|
||||
message_id=id1,
|
||||
conversation_id="conversation-1",
|
||||
parent_message_id=UUID_NIL,
|
||||
answer="ok",
|
||||
created_at=now - timedelta(seconds=1),
|
||||
),
|
||||
_persisted_message(
|
||||
message_id=str(uuid4()),
|
||||
conversation_id="other-conversation",
|
||||
parent_message_id=UUID_NIL,
|
||||
answer="unrelated",
|
||||
created_at=now + timedelta(seconds=1),
|
||||
),
|
||||
]
|
||||
sqlite_session.add_all(messages)
|
||||
sqlite_session.commit()
|
||||
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = messages
|
||||
|
||||
length = get_thread_messages_length("conversation-1", session=session)
|
||||
length = get_thread_messages_length("conversation-1", session=sqlite_session)
|
||||
|
||||
assert length == 1
|
||||
session.scalars.assert_called_once()
|
||||
|
||||
|
||||
def test_get_thread_messages_length_keeps_non_empty_latest_answer():
|
||||
@pytest.mark.parametrize("sqlite_session", [(Message,)], indirect=True)
|
||||
def test_get_thread_messages_length_keeps_non_empty_latest_answer(sqlite_session: Session):
|
||||
id1, id2 = str(uuid4()), str(uuid4())
|
||||
now = datetime.now()
|
||||
messages = [
|
||||
MockMessage(id2, id1, answer="latest-answer"),
|
||||
MockMessage(id1, UUID_NIL, answer="older-answer"),
|
||||
_persisted_message(
|
||||
message_id=id2,
|
||||
conversation_id="conversation-2",
|
||||
parent_message_id=id1,
|
||||
answer="latest-answer",
|
||||
created_at=now,
|
||||
),
|
||||
_persisted_message(
|
||||
message_id=id1,
|
||||
conversation_id="conversation-2",
|
||||
parent_message_id=UUID_NIL,
|
||||
answer="older-answer",
|
||||
created_at=now - timedelta(seconds=1),
|
||||
),
|
||||
]
|
||||
sqlite_session.add_all(messages)
|
||||
sqlite_session.commit()
|
||||
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = messages
|
||||
|
||||
length = get_thread_messages_length("conversation-2", session=session)
|
||||
length = get_thread_messages_length("conversation-2", session=sqlite_session)
|
||||
|
||||
assert length == 2
|
||||
session.scalars.assert_called_once()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,16 +1,16 @@
|
||||
"""Unit tests for workflow-as-tool behavior.
|
||||
|
||||
StubSession/StubScalars emulate SQLAlchemy session/scalars with minimal methods
|
||||
(`scalar`, `scalars`, `expunge`, `commit`, `refresh`, context manager) to keep
|
||||
database access mocked and predictable in tests.
|
||||
"""
|
||||
"""Unit tests for workflow-as-tool behavior with real SQLite ORM boundaries."""
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine, inspect
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
from core.tools.__base.tool_runtime import ToolRuntime
|
||||
@@ -23,74 +23,142 @@ from core.tools.entities.tool_entities import (
|
||||
ToolProviderType,
|
||||
)
|
||||
from core.tools.errors import ToolInvokeError
|
||||
from core.tools.workflow_as_tool import tool as workflow_tool_module
|
||||
from core.tools.workflow_as_tool.tool import WorkflowTool
|
||||
from graphon.file import FILE_MODEL_IDENTITY, FileTransferMethod, FileType
|
||||
from models.account import Account, Tenant, TenantAccountJoin, TenantAccountRole
|
||||
from models.base import TypeBase
|
||||
from models.enums import EndUserType
|
||||
from models.model import App, AppMode, EndUser
|
||||
from models.workflow import Workflow, WorkflowType
|
||||
|
||||
TENANT_ID = "00000000-0000-0000-0000-000000000001"
|
||||
OTHER_TENANT_ID = "00000000-0000-0000-0000-000000000002"
|
||||
APP_ID = "00000000-0000-0000-0000-000000000003"
|
||||
ACCOUNT_ID = "00000000-0000-0000-0000-000000000004"
|
||||
END_USER_ID = "00000000-0000-0000-0000-000000000005"
|
||||
CREATOR_ID = "00000000-0000-0000-0000-000000000006"
|
||||
|
||||
|
||||
class StubScalars:
|
||||
"""Minimal stub for SQLAlchemy scalar results."""
|
||||
|
||||
_value: Any
|
||||
|
||||
def __init__(self, value: Any) -> None:
|
||||
self._value = value
|
||||
|
||||
def first(self) -> Any:
|
||||
return self._value
|
||||
@dataclass(frozen=True)
|
||||
class SqliteToolDb:
|
||||
engine: Engine
|
||||
session_maker: sessionmaker[Session]
|
||||
caller_session: Session
|
||||
|
||||
|
||||
class StubSession:
|
||||
"""Minimal stub for session_factory-created sessions."""
|
||||
@pytest.fixture
|
||||
def sqlite_tool_db(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_engine: Engine,
|
||||
) -> Iterator[SqliteToolDb]:
|
||||
"""Bind service-owned sessions and Account tenant reloads to SQLite."""
|
||||
models = (App, Workflow, EndUser, Account, Tenant, TenantAccountJoin)
|
||||
TypeBase.metadata.create_all(sqlite_engine, tables=[model.__table__ for model in models])
|
||||
session_maker = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
monkeypatch.setattr(workflow_tool_module.session_factory, "create_session", session_maker)
|
||||
|
||||
scalar_results: list[Any]
|
||||
scalars_results: list[Any]
|
||||
expunge_calls: list[object]
|
||||
from models import account as account_module
|
||||
|
||||
def __init__(self, *, scalar_results: list[Any] | None = None, scalars_results: list[Any] | None = None) -> None:
|
||||
self.scalar_results = list(scalar_results or [])
|
||||
self.scalars_results = list(scalars_results or [])
|
||||
self.expunge_calls: list[object] = []
|
||||
|
||||
def scalar(self, _stmt: Any) -> Any:
|
||||
return self.scalar_results.pop(0)
|
||||
|
||||
def scalars(self, _stmt: Any) -> StubScalars:
|
||||
return StubScalars(self.scalars_results.pop(0))
|
||||
|
||||
def expunge(self, value: Any) -> None:
|
||||
self.expunge_calls.append(value)
|
||||
|
||||
def begin(self) -> "StubSession":
|
||||
return self
|
||||
|
||||
def commit(self) -> None:
|
||||
pass
|
||||
|
||||
def refresh(self, _value: Any) -> None:
|
||||
pass
|
||||
|
||||
def close(self) -> None:
|
||||
pass
|
||||
|
||||
def __enter__(self) -> "StubSession":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> bool:
|
||||
return False
|
||||
monkeypatch.setattr(account_module, "db", SimpleNamespace(engine=sqlite_engine))
|
||||
with session_maker() as caller_session:
|
||||
yield SqliteToolDb(engine=sqlite_engine, session_maker=session_maker, caller_session=caller_session)
|
||||
|
||||
|
||||
def _build_tool() -> WorkflowTool:
|
||||
def _persist_tenant(db: SqliteToolDb, *, tenant_id: str = TENANT_ID) -> Tenant:
|
||||
tenant = Tenant(name="Tenant")
|
||||
tenant.id = tenant_id
|
||||
db.caller_session.add(tenant)
|
||||
db.caller_session.commit()
|
||||
return tenant
|
||||
|
||||
|
||||
def _persist_account(db: SqliteToolDb, *, tenant_id: str = TENANT_ID) -> Account:
|
||||
account = Account(name="Account", email="account@example.com")
|
||||
account.id = ACCOUNT_ID
|
||||
join = TenantAccountJoin(
|
||||
tenant_id=tenant_id,
|
||||
account_id=account.id,
|
||||
current=True,
|
||||
role=TenantAccountRole.NORMAL,
|
||||
)
|
||||
db.caller_session.add_all([account, join])
|
||||
db.caller_session.commit()
|
||||
return account
|
||||
|
||||
|
||||
def _persist_end_user(
|
||||
db: SqliteToolDb,
|
||||
*,
|
||||
end_user_id: str = END_USER_ID,
|
||||
tenant_id: str = TENANT_ID,
|
||||
) -> EndUser:
|
||||
end_user = EndUser(
|
||||
id=end_user_id,
|
||||
tenant_id=tenant_id,
|
||||
app_id=APP_ID,
|
||||
type=EndUserType.SERVICE_API,
|
||||
name="End user",
|
||||
session_id="end-user-session",
|
||||
)
|
||||
db.caller_session.add(end_user)
|
||||
db.caller_session.commit()
|
||||
return end_user
|
||||
|
||||
|
||||
def _persist_app(db: SqliteToolDb) -> App:
|
||||
app = App(
|
||||
id=APP_ID,
|
||||
tenant_id=TENANT_ID,
|
||||
name="Workflow app",
|
||||
description="",
|
||||
mode=AppMode.WORKFLOW,
|
||||
icon_type=None,
|
||||
icon="",
|
||||
icon_background=None,
|
||||
app_model_config_id=None,
|
||||
workflow_id=None,
|
||||
enable_site=False,
|
||||
enable_api=True,
|
||||
max_active_requests=None,
|
||||
created_by=CREATOR_ID,
|
||||
)
|
||||
db.caller_session.add(app)
|
||||
db.caller_session.commit()
|
||||
return app
|
||||
|
||||
|
||||
def _persist_workflow(db: SqliteToolDb, *, version: str, workflow_id: str | None = None) -> Workflow:
|
||||
workflow = Workflow.new(
|
||||
tenant_id=TENANT_ID,
|
||||
app_id=APP_ID,
|
||||
type=WorkflowType.WORKFLOW.value,
|
||||
version=version,
|
||||
graph=json.dumps({"nodes": [], "edges": []}),
|
||||
features="{}",
|
||||
created_by=CREATOR_ID,
|
||||
environment_variables=[],
|
||||
conversation_variables=[],
|
||||
rag_pipeline_variables=[],
|
||||
)
|
||||
workflow.id = workflow_id or str(uuid.uuid4())
|
||||
db.caller_session.add(workflow)
|
||||
db.caller_session.commit()
|
||||
return workflow
|
||||
|
||||
|
||||
def _build_tool(*, tenant_id: str = "test_tool", workflow_app_id: str = "app-1", version: str = "1") -> WorkflowTool:
|
||||
entity = ToolEntity(
|
||||
identity=ToolIdentity(author="test", name="test tool", label=I18nObject(en_US="test tool"), provider="test"),
|
||||
parameters=[],
|
||||
description=None,
|
||||
has_runtime_parameters=False,
|
||||
)
|
||||
runtime = ToolRuntime(tenant_id="test_tool", invoke_from=InvokeFrom.EXPLORE)
|
||||
runtime = ToolRuntime(tenant_id=tenant_id, invoke_from=InvokeFrom.EXPLORE)
|
||||
return WorkflowTool(
|
||||
workflow_app_id="app-1",
|
||||
workflow_app_id=workflow_app_id,
|
||||
workflow_as_tool_id="wf-tool-1",
|
||||
version="1",
|
||||
version=version,
|
||||
workflow_entities={},
|
||||
workflow_call_depth=1,
|
||||
entity=entity,
|
||||
@@ -98,7 +166,10 @@ def _build_tool() -> WorkflowTool:
|
||||
)
|
||||
|
||||
|
||||
def test_workflow_tool_should_raise_tool_invoke_error_when_result_has_error_field(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_workflow_tool_should_raise_tool_invoke_error_when_result_has_error_field(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Ensure that WorkflowTool will throw a `ToolInvokeError` exception when
|
||||
`WorkflowAppGenerator.generate` returns a result with `error` key inside
|
||||
the `data` element.
|
||||
@@ -122,11 +193,14 @@ def test_workflow_tool_should_raise_tool_invoke_error_when_result_has_error_fiel
|
||||
with pytest.raises(ToolInvokeError) as exc_info:
|
||||
# WorkflowTool always returns a generator, so we need to iterate to
|
||||
# actually `run` the tool.
|
||||
list(tool.invoke(MagicMock(), "test_user", {}))
|
||||
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
|
||||
assert exc_info.value.args == ("oops",)
|
||||
|
||||
|
||||
def test_workflow_tool_does_not_use_pause_state_config(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_workflow_tool_does_not_use_pause_state_config(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Ensure pause_state_config is passed as None."""
|
||||
tool = _build_tool()
|
||||
|
||||
@@ -140,14 +214,17 @@ def test_workflow_tool_does_not_use_pause_state_config(monkeypatch: pytest.Monke
|
||||
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
|
||||
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
|
||||
|
||||
list(tool.invoke(MagicMock(), "test_user", {}))
|
||||
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
|
||||
|
||||
call_kwargs = generate_mock.call_args.kwargs
|
||||
assert "pause_state_config" in call_kwargs
|
||||
assert call_kwargs["pause_state_config"] is None
|
||||
|
||||
|
||||
def test_workflow_tool_passes_parent_trace_context_from_runtime(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_workflow_tool_passes_parent_trace_context_from_runtime(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Ensure nested workflow runtime metadata is forwarded as parent trace context."""
|
||||
tool = _build_tool()
|
||||
tool.set_parent_trace_context(
|
||||
@@ -165,7 +242,7 @@ def test_workflow_tool_passes_parent_trace_context_from_runtime(monkeypatch: pyt
|
||||
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
|
||||
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
|
||||
|
||||
list(tool.invoke(MagicMock(), "test_user", {}))
|
||||
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
|
||||
|
||||
call_kwargs = generate_mock.call_args.kwargs
|
||||
assert call_kwargs["args"]["parent_trace_context"].model_dump() == {
|
||||
@@ -174,7 +251,10 @@ def test_workflow_tool_passes_parent_trace_context_from_runtime(monkeypatch: pyt
|
||||
}
|
||||
|
||||
|
||||
def test_workflow_tool_passes_parent_trace_session_id(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_workflow_tool_passes_parent_trace_session_id(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Ensure nested workflows inherit the parent observability session ID."""
|
||||
tool = _build_tool()
|
||||
tool.entity.parameters = [
|
||||
@@ -197,14 +277,17 @@ def test_workflow_tool_passes_parent_trace_session_id(monkeypatch: pytest.Monkey
|
||||
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
|
||||
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
|
||||
|
||||
list(tool.invoke(MagicMock(), "test_user", {"trace_session_id": "user-input-session"}))
|
||||
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {"trace_session_id": "user-input-session"}))
|
||||
|
||||
call_kwargs = generate_mock.call_args.kwargs
|
||||
assert call_kwargs["args"]["inputs"]["trace_session_id"] == "user-input-session"
|
||||
assert call_kwargs["args"]["trace_session_id"] == "session-1"
|
||||
|
||||
|
||||
def test_workflow_tool_keeps_user_inputs_named_like_trace_runtime_keys(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_workflow_tool_keeps_user_inputs_named_like_trace_runtime_keys(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Ensure private trace context does not overwrite same-named workflow inputs."""
|
||||
tool = _build_tool()
|
||||
tool.entity.parameters = [
|
||||
@@ -238,7 +321,7 @@ def test_workflow_tool_keeps_user_inputs_named_like_trace_runtime_keys(monkeypat
|
||||
|
||||
list(
|
||||
tool.invoke(
|
||||
MagicMock(),
|
||||
sqlite_tool_db.caller_session,
|
||||
"test_user",
|
||||
{
|
||||
"outer_workflow_run_id": "user-workflow-input",
|
||||
@@ -256,7 +339,10 @@ def test_workflow_tool_keeps_user_inputs_named_like_trace_runtime_keys(monkeypat
|
||||
}
|
||||
|
||||
|
||||
def test_workflow_tool_can_clear_parent_trace_context(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_workflow_tool_can_clear_parent_trace_context(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Ensure reused WorkflowTool instances do not keep stale parent trace context."""
|
||||
tool = _build_tool()
|
||||
tool.set_parent_trace_context(
|
||||
@@ -275,13 +361,16 @@ def test_workflow_tool_can_clear_parent_trace_context(monkeypatch: pytest.Monkey
|
||||
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
|
||||
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
|
||||
|
||||
list(tool.invoke(MagicMock(), "test_user", {}))
|
||||
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
|
||||
|
||||
call_kwargs = generate_mock.call_args.kwargs
|
||||
assert "parent_trace_context" not in call_kwargs["args"]
|
||||
|
||||
|
||||
def test_workflow_tool_can_clear_trace_session_id(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_workflow_tool_can_clear_trace_session_id(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Ensure reused WorkflowTool instances do not keep stale trace session IDs."""
|
||||
tool = _build_tool()
|
||||
tool.set_trace_session_id("session-1")
|
||||
@@ -297,7 +386,7 @@ def test_workflow_tool_can_clear_trace_session_id(monkeypatch: pytest.MonkeyPatc
|
||||
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
|
||||
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
|
||||
|
||||
list(tool.invoke(MagicMock(), "test_user", {}))
|
||||
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
|
||||
|
||||
call_kwargs = generate_mock.call_args.kwargs
|
||||
assert "trace_session_id" not in call_kwargs["args"]
|
||||
@@ -315,6 +404,7 @@ def test_workflow_tool_can_clear_trace_session_id(monkeypatch: pytest.MonkeyPatc
|
||||
def test_workflow_tool_omits_parent_trace_context_when_runtime_is_incomplete(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
runtime_parameters: dict[str, Any],
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Ensure incomplete runtime metadata does not leak parent trace context into generator args."""
|
||||
tool = _build_tool()
|
||||
@@ -330,13 +420,16 @@ def test_workflow_tool_omits_parent_trace_context_when_runtime_is_incomplete(
|
||||
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
|
||||
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
|
||||
|
||||
list(tool.invoke(MagicMock(), "test_user", {}))
|
||||
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
|
||||
|
||||
call_kwargs = generate_mock.call_args.kwargs
|
||||
assert "parent_trace_context" not in call_kwargs["args"]
|
||||
|
||||
|
||||
def test_workflow_tool_should_generate_variable_messages_for_outputs(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_workflow_tool_should_generate_variable_messages_for_outputs(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Test that WorkflowTool should generate variable messages when there are outputs"""
|
||||
tool = _build_tool()
|
||||
|
||||
@@ -359,7 +452,7 @@ def test_workflow_tool_should_generate_variable_messages_for_outputs(monkeypatch
|
||||
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
|
||||
|
||||
# Execute tool invocation
|
||||
messages = list(tool.invoke(MagicMock(), "test_user", {}))
|
||||
messages = list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
|
||||
|
||||
# Verify variable messages
|
||||
variable_messages = [msg for msg in messages if msg.type == ToolInvokeMessage.MessageType.VARIABLE]
|
||||
@@ -382,7 +475,10 @@ def test_workflow_tool_should_generate_variable_messages_for_outputs(monkeypatch
|
||||
assert json_messages[0].message.json_object == mock_outputs
|
||||
|
||||
|
||||
def test_workflow_tool_should_handle_empty_outputs(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_workflow_tool_should_handle_empty_outputs(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Test that WorkflowTool should handle empty outputs correctly"""
|
||||
tool = _build_tool()
|
||||
|
||||
@@ -402,7 +498,7 @@ def test_workflow_tool_should_handle_empty_outputs(monkeypatch: pytest.MonkeyPat
|
||||
monkeypatch.setattr("libs.login.current_user", lambda *args, **kwargs: None)
|
||||
|
||||
# Execute tool invocation
|
||||
messages = list(tool.invoke(MagicMock(), "test_user", {}))
|
||||
messages = list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {}))
|
||||
|
||||
# Verify generated messages
|
||||
# Should contain: 0 variable messages + 1 text message + 1 JSON message = 2 messages
|
||||
@@ -458,41 +554,32 @@ def test_create_file_message_should_include_file_marker():
|
||||
assert message.meta == {"file": file_obj}
|
||||
|
||||
|
||||
def test_resolve_user_from_database_falls_back_to_end_user(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_resolve_user_from_database_falls_back_to_end_user(sqlite_tool_db: SqliteToolDb):
|
||||
"""Ensure worker context can resolve EndUser when Account is missing."""
|
||||
|
||||
tenant = SimpleNamespace(id="tenant_id")
|
||||
end_user = SimpleNamespace(id="end_user_id", tenant_id="tenant_id")
|
||||
|
||||
# Monkeypatch session factory to return our stub session
|
||||
stub_session = StubSession(scalar_results=[tenant, None, end_user])
|
||||
monkeypatch.setattr(
|
||||
"core.tools.workflow_as_tool.tool.session_factory.create_session",
|
||||
lambda: stub_session,
|
||||
_persist_tenant(sqlite_tool_db)
|
||||
end_user = _persist_end_user(sqlite_tool_db)
|
||||
other_tenant_end_user = _persist_end_user(
|
||||
sqlite_tool_db,
|
||||
end_user_id="00000000-0000-0000-0000-000000000007",
|
||||
tenant_id=OTHER_TENANT_ID,
|
||||
)
|
||||
|
||||
tool = _build_tool()
|
||||
tool = _build_tool(tenant_id=TENANT_ID)
|
||||
tool.runtime.invoke_from = InvokeFrom.SERVICE_API
|
||||
tool.runtime.tenant_id = "tenant_id"
|
||||
|
||||
resolved_user = tool._resolve_user_from_database(user_id=end_user.id)
|
||||
|
||||
assert resolved_user is end_user
|
||||
assert stub_session.expunge_calls == [end_user]
|
||||
assert isinstance(resolved_user, EndUser)
|
||||
assert resolved_user.id == end_user.id
|
||||
assert resolved_user.tenant_id == TENANT_ID
|
||||
assert inspect(resolved_user).detached is True
|
||||
assert tool._resolve_user_from_database(user_id=other_tenant_end_user.id) is None
|
||||
|
||||
|
||||
def test_resolve_user_from_database_returns_none_when_no_tenant(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_resolve_user_from_database_returns_none_when_no_tenant(sqlite_tool_db: SqliteToolDb):
|
||||
"""Return None if tenant cannot be found in worker context."""
|
||||
|
||||
# Monkeypatch session factory to return our stub session with no tenant
|
||||
monkeypatch.setattr(
|
||||
"core.tools.workflow_as_tool.tool.session_factory.create_session",
|
||||
lambda: StubSession(scalar_results=[None]),
|
||||
)
|
||||
|
||||
tool = _build_tool()
|
||||
tool = _build_tool(tenant_id=OTHER_TENANT_ID)
|
||||
tool.runtime.invoke_from = InvokeFrom.SERVICE_API
|
||||
tool.runtime.tenant_id = "missing_tenant"
|
||||
|
||||
resolved_user = tool._resolve_user_from_database(user_id="any")
|
||||
|
||||
@@ -544,7 +631,10 @@ def test_extract_usage_from_nested():
|
||||
assert nested == {"total_tokens": 3}
|
||||
|
||||
|
||||
def test_invoke_raises_when_user_not_found(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_invoke_raises_when_user_not_found(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Raise ToolInvokeError when user resolution fails."""
|
||||
tool = _build_tool()
|
||||
monkeypatch.setattr(tool, "_get_app", lambda *args, **kwargs: None)
|
||||
@@ -552,58 +642,45 @@ def test_invoke_raises_when_user_not_found(monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(tool, "_resolve_user", lambda *args, **kwargs: None)
|
||||
|
||||
with pytest.raises(ToolInvokeError, match="User not found"):
|
||||
list(tool.invoke(MagicMock(), "missing", {}))
|
||||
list(tool.invoke(sqlite_tool_db.caller_session, "missing", {}))
|
||||
|
||||
|
||||
def test_resolve_user_from_database_returns_account(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_resolve_user_from_database_returns_account(sqlite_tool_db: SqliteToolDb):
|
||||
"""Resolve Account and set tenant in worker context."""
|
||||
tenant = SimpleNamespace(id="tenant_id")
|
||||
account = SimpleNamespace(id="account_id", current_tenant=None)
|
||||
set_current_tenant = Mock(side_effect=lambda tenant, *, session: setattr(account, "current_tenant", tenant))
|
||||
account.set_current_tenant_with_session = set_current_tenant
|
||||
session = StubSession(scalar_results=[tenant, account])
|
||||
tenant = _persist_tenant(sqlite_tool_db)
|
||||
account = _persist_account(sqlite_tool_db)
|
||||
tool = _build_tool(tenant_id=TENANT_ID)
|
||||
|
||||
monkeypatch.setattr("core.tools.workflow_as_tool.tool.session_factory.create_session", lambda: session)
|
||||
tool = _build_tool()
|
||||
tool.runtime.tenant_id = "tenant_id"
|
||||
|
||||
resolved = tool._resolve_user_from_database(user_id="account_id")
|
||||
assert resolved is account
|
||||
assert account.current_tenant is tenant
|
||||
set_current_tenant.assert_called_once_with(tenant, session=session)
|
||||
assert session.expunge_calls == [account]
|
||||
resolved = tool._resolve_user_from_database(user_id=account.id)
|
||||
assert isinstance(resolved, Account)
|
||||
assert resolved.id == account.id
|
||||
assert resolved.current_tenant_id == tenant.id
|
||||
assert inspect(resolved).detached is True
|
||||
|
||||
|
||||
def test_get_workflow_and_get_app_db_branches(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_get_workflow_and_get_app_db_branches(sqlite_tool_db: SqliteToolDb):
|
||||
"""Cover workflow/app retrieval branches and error cases."""
|
||||
tool = _build_tool()
|
||||
latest_workflow = SimpleNamespace(id="wf-latest")
|
||||
specific_workflow = SimpleNamespace(id="wf-v1")
|
||||
app = SimpleNamespace(id="app-1")
|
||||
sessions = iter(
|
||||
[
|
||||
StubSession(scalar_results=[], scalars_results=[latest_workflow]),
|
||||
StubSession(scalar_results=[specific_workflow], scalars_results=[]),
|
||||
StubSession(scalar_results=[app], scalars_results=[]),
|
||||
]
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.tools.workflow_as_tool.tool.session_factory.create_session",
|
||||
lambda: next(sessions),
|
||||
)
|
||||
app = _persist_app(sqlite_tool_db)
|
||||
specific_workflow = _persist_workflow(sqlite_tool_db, version="1")
|
||||
latest_workflow = _persist_workflow(sqlite_tool_db, version="2")
|
||||
_persist_workflow(sqlite_tool_db, version=Workflow.VERSION_DRAFT)
|
||||
tool = _build_tool(tenant_id=TENANT_ID, workflow_app_id=APP_ID)
|
||||
|
||||
assert tool._get_workflow("app-1", "") is latest_workflow
|
||||
assert tool._get_workflow("app-1", "1") is specific_workflow
|
||||
assert tool._get_app("app-1") is app
|
||||
latest = tool._get_workflow(APP_ID, "")
|
||||
specific = tool._get_workflow(APP_ID, "1")
|
||||
resolved_app = tool._get_app(APP_ID)
|
||||
|
||||
assert latest.id == latest_workflow.id
|
||||
assert specific.id == specific_workflow.id
|
||||
assert resolved_app.id == app.id
|
||||
assert inspect(latest).detached is True
|
||||
assert inspect(specific).detached is True
|
||||
assert inspect(resolved_app).detached is True
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.tools.workflow_as_tool.tool.session_factory.create_session",
|
||||
lambda: StubSession(scalar_results=[None, None], scalars_results=[None]),
|
||||
)
|
||||
with pytest.raises(ValueError, match="workflow not found"):
|
||||
tool._get_workflow("app-1", "1")
|
||||
tool._get_workflow(APP_ID, "missing")
|
||||
with pytest.raises(ValueError, match="app not found"):
|
||||
tool._get_app("app-1")
|
||||
tool._get_app("00000000-0000-0000-0000-000000000099")
|
||||
|
||||
|
||||
def _setup_transform_args_tool(monkeypatch: pytest.MonkeyPatch) -> WorkflowTool:
|
||||
@@ -722,7 +799,10 @@ def test_transform_args_normalizes_optional_files_parameter(
|
||||
assert files == []
|
||||
|
||||
|
||||
def test_workflow_tool_invocation_normalizes_optional_files_parameter(monkeypatch: pytest.MonkeyPatch):
|
||||
def test_workflow_tool_invocation_normalizes_optional_files_parameter(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlite_tool_db: SqliteToolDb,
|
||||
):
|
||||
"""Ensure casted empty FILES values do not reach workflow input validation as [None]."""
|
||||
tool = _build_tool()
|
||||
images_param = ToolParameter.get_simple_instance(
|
||||
@@ -741,7 +821,7 @@ def test_workflow_tool_invocation_normalizes_optional_files_parameter(monkeypatc
|
||||
generate_mock = MagicMock(return_value={"data": {}})
|
||||
monkeypatch.setattr("core.app.apps.workflow.app_generator.WorkflowAppGenerator.generate", generate_mock)
|
||||
|
||||
list(tool.invoke(MagicMock(), "test_user", {"images": None}))
|
||||
list(tool.invoke(sqlite_tool_db.caller_session, "test_user", {"images": None}))
|
||||
|
||||
call_kwargs = generate_mock.call_args.kwargs
|
||||
assert call_kwargs["args"]["inputs"]["images"] == []
|
||||
|
||||
@@ -18,7 +18,6 @@ from models.provider import ProviderType
|
||||
@pytest.fixture
|
||||
def credit_pool_session_factory(sqlite_engine: Engine) -> Iterator[sessionmaker[Session]]:
|
||||
"""Bind message-created accounting to fixture-owned SQLite sessions."""
|
||||
TenantCreditPool.__table__.create(sqlite_engine)
|
||||
session_factory = sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
with patch("events.event_handlers.update_provider_when_message_created.db.session", session_factory):
|
||||
yield session_factory
|
||||
|
||||
@@ -246,6 +246,26 @@ class TestPluginModelProviderCache:
|
||||
call([cache_key]),
|
||||
]
|
||||
|
||||
def test_fetch_plugin_model_providers_bypasses_redis_when_cache_disabled(self) -> None:
|
||||
"""With the cache disabled the daemon is the only source, and Redis is never touched."""
|
||||
with patch(f"{MODULE}.redis_client") as redis_client, patch(f"{MODULE}.dify_config") as config:
|
||||
config.PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED = False
|
||||
client = Mock()
|
||||
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
first = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
second = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
assert [provider.provider for provider in first] == ["langgenius/openai/openai"]
|
||||
assert [provider.provider for provider in second] == ["langgenius/openai/openai"]
|
||||
assert client.fetch_model_providers.call_count == 2
|
||||
redis_client.get.assert_not_called()
|
||||
redis_client.mget.assert_not_called()
|
||||
redis_client.setex.assert_not_called()
|
||||
redis_client.lock.assert_not_called()
|
||||
|
||||
def test_fetch_plugin_model_providers_refetches_when_cache_read_fails(self) -> None:
|
||||
"""Redis read failures do not block provider discovery for the tenant."""
|
||||
with patch(f"{MODULE}.redis_client") as redis_client:
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import json
|
||||
from collections.abc import Iterator
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import UUID
|
||||
@@ -11,7 +10,6 @@ from sqlalchemy.orm import Session
|
||||
from configs import dify_config
|
||||
from models.account import (
|
||||
Account,
|
||||
AccountIntegrate,
|
||||
AccountStatus,
|
||||
Tenant,
|
||||
TenantAccountJoin,
|
||||
@@ -114,22 +112,6 @@ class TestAccountService:
|
||||
- Error conditions and edge cases
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def sqlite_session(self, sqlite_engine) -> Iterator[Session]:
|
||||
"""SQLite session with the account/workspace tables these service tests touch."""
|
||||
tables = [
|
||||
model.metadata.tables[model.__tablename__]
|
||||
for model in (
|
||||
Account,
|
||||
Tenant,
|
||||
TenantAccountJoin,
|
||||
TenantPluginAutoUpgradeStrategy,
|
||||
)
|
||||
]
|
||||
Account.metadata.create_all(sqlite_engine, tables=tables)
|
||||
with Session(sqlite_engine, expire_on_commit=False) as session:
|
||||
yield session
|
||||
|
||||
@pytest.fixture
|
||||
def mock_password_dependencies(self):
|
||||
"""Mock setup for password-related functions."""
|
||||
@@ -1264,24 +1246,6 @@ class TestRegisterService:
|
||||
- Error conditions and edge cases
|
||||
"""
|
||||
|
||||
@pytest.fixture
|
||||
def sqlite_session(self, sqlite_engine) -> Iterator[Session]:
|
||||
"""SQLite session with the account/workspace tables registration flows touch."""
|
||||
tables = [
|
||||
model.metadata.tables[model.__tablename__]
|
||||
for model in (
|
||||
Account,
|
||||
AccountIntegrate,
|
||||
Tenant,
|
||||
TenantAccountJoin,
|
||||
TenantPluginAutoUpgradeStrategy,
|
||||
DifySetup,
|
||||
)
|
||||
]
|
||||
Account.metadata.create_all(sqlite_engine, tables=tables)
|
||||
with Session(sqlite_engine, expire_on_commit=False) as session:
|
||||
yield session
|
||||
|
||||
@pytest.fixture
|
||||
def mock_redis_dependencies(self):
|
||||
"""Mock setup for Redis-related functions."""
|
||||
|
||||
@@ -1,19 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from types import SimpleNamespace
|
||||
from typing import cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from models import Account
|
||||
from models.model import App, AppMode, AppModelConfig
|
||||
from models.model import App, AppMode, AppModelConfig, IconType
|
||||
from models.workflow import Workflow
|
||||
from services.agent.errors import AgentNameConflictError
|
||||
from services.app_service import AppService, CreateAppParams
|
||||
from services.app_service import AppListParams, AppService, CreateAppParams
|
||||
|
||||
|
||||
class TestCreateAppTransactionBoundary:
|
||||
@@ -236,6 +240,92 @@ class TestOpenapiVisibilityHelpers:
|
||||
mock_session.execute.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Account, App, AppModelConfig)], indirect=True)
|
||||
def test_get_recent_apps_uses_one_tenant_scoped_projection_query(sqlite_session: Session) -> None:
|
||||
tenant_id = str(uuid4())
|
||||
other_tenant_id = str(uuid4())
|
||||
account = Account(name="Recent Apps Author", email="recent-apps@example.com")
|
||||
sqlite_session.add(account)
|
||||
sqlite_session.flush()
|
||||
|
||||
def create_app(*, name: str, tenant_id: str, updated_at: datetime, mode: AppMode = AppMode.CHAT) -> App:
|
||||
app = App()
|
||||
app.id = str(uuid4())
|
||||
app.tenant_id = tenant_id
|
||||
app.name = name
|
||||
app.description = ""
|
||||
app.mode = mode
|
||||
app.icon_type = IconType.EMOJI
|
||||
app.icon = "🚀"
|
||||
app.icon_background = "#FFFFFF"
|
||||
app.enable_site = False
|
||||
app.enable_api = False
|
||||
app.created_by = account.id
|
||||
app.maintainer = account.id
|
||||
app.created_at = updated_at
|
||||
app.updated_at = updated_at
|
||||
app.use_icon_as_answer_icon = False
|
||||
return app
|
||||
|
||||
newest = create_app(name="Newest", tenant_id=tenant_id, updated_at=datetime(2026, 7, 3))
|
||||
legacy_agent = AppModelConfig(app_id=newest.id)
|
||||
legacy_agent.agent_mode = '{"enabled": true, "strategy": "react"}'
|
||||
newest.app_model_config_id = legacy_agent.id
|
||||
second = create_app(
|
||||
name="Second",
|
||||
tenant_id=tenant_id,
|
||||
updated_at=datetime(2026, 7, 2),
|
||||
mode=AppMode.WORKFLOW,
|
||||
)
|
||||
second.icon_type = None
|
||||
second.icon = None
|
||||
second.icon_background = None
|
||||
second.created_by = None
|
||||
second.maintainer = None
|
||||
channel = create_app(
|
||||
name="Channel",
|
||||
tenant_id=tenant_id,
|
||||
updated_at=datetime(2026, 7, 5),
|
||||
mode=AppMode.CHANNEL,
|
||||
)
|
||||
rag_pipeline = create_app(
|
||||
name="RAG Pipeline",
|
||||
tenant_id=tenant_id,
|
||||
updated_at=datetime(2026, 7, 4),
|
||||
mode=AppMode.RAG_PIPELINE,
|
||||
)
|
||||
oldest = create_app(name="Oldest", tenant_id=tenant_id, updated_at=datetime(2026, 7, 1))
|
||||
foreign = create_app(name="Foreign", tenant_id=other_tenant_id, updated_at=datetime(2026, 7, 4))
|
||||
sqlite_session.add_all([newest, legacy_agent, second, channel, rag_pipeline, oldest, foreign])
|
||||
sqlite_session.commit()
|
||||
|
||||
statements: list[str] = []
|
||||
bind = sqlite_session.get_bind()
|
||||
|
||||
def record_sql(_conn, _cursor, statement, _parameters, _context, _executemany) -> None:
|
||||
statements.append(statement)
|
||||
|
||||
event.listen(bind, "before_cursor_execute", record_sql)
|
||||
try:
|
||||
recent_apps = AppService().get_recent_apps(
|
||||
account.id,
|
||||
tenant_id,
|
||||
AppListParams(limit=2),
|
||||
sqlite_session,
|
||||
)
|
||||
finally:
|
||||
event.remove(bind, "before_cursor_execute", record_sql)
|
||||
|
||||
assert [(app.name, app.mode, app.icon_type, app.author_name, app.maintainer) for app in recent_apps] == [
|
||||
("Newest", AppMode.CHAT, IconType.EMOJI, "Recent Apps Author", account.id),
|
||||
("Second", AppMode.WORKFLOW, None, None, None),
|
||||
]
|
||||
select_statements = [statement for statement in statements if statement.lstrip().upper().startswith("SELECT")]
|
||||
assert len(select_statements) == 1
|
||||
assert "count(" not in select_statements[0].lower()
|
||||
assert "app_model_configs" not in select_statements[0].lower()
|
||||
|
||||
|
||||
class TestAppMeta:
|
||||
def test_loads_workflow_with_caller_session(self):
|
||||
session = MagicMock()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,16 @@
|
||||
"""Workflow-run service tests with real SQLite-bound session factories."""
|
||||
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, cast
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine
|
||||
from sqlalchemy import Engine, event
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from models import Account, App, EndUser, WorkflowRunTriggeredFrom
|
||||
from models import Account, App, EndUser, Message, WorkflowRunTriggeredFrom
|
||||
from models.enums import ConversationFromSource
|
||||
from services import workflow_run_service as service_module
|
||||
from services.workflow_run_service import WorkflowRunService
|
||||
|
||||
@@ -22,6 +27,11 @@ def repository_factory_mocks(monkeypatch: pytest.MonkeyPatch) -> tuple[MagicMock
|
||||
return node_repo, workflow_run_repo, factory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sqlalchemy_session_factory(sqlite_engine: Engine) -> sessionmaker[Session]:
|
||||
return sessionmaker(bind=sqlite_engine, expire_on_commit=False)
|
||||
|
||||
|
||||
def _app_model(**kwargs: Any) -> App:
|
||||
return cast(App, SimpleNamespace(**kwargs))
|
||||
|
||||
@@ -34,13 +44,22 @@ def _end_user(**kwargs: Any) -> EndUser:
|
||||
return cast(EndUser, SimpleNamespace(**kwargs))
|
||||
|
||||
|
||||
def _fake_session_factory_returning_messages(messages: list[Any]) -> tuple[MagicMock, MagicMock]:
|
||||
"""Build a session factory whose session returns the given messages."""
|
||||
session = MagicMock()
|
||||
session.scalars.return_value.all.return_value = messages
|
||||
session_factory = MagicMock()
|
||||
session_factory.return_value.__enter__.return_value = session
|
||||
return session_factory, session
|
||||
def _message(*, message_id: str, workflow_run_id: str, conversation_id: str) -> Message:
|
||||
message = Message(
|
||||
app_id="app-1",
|
||||
conversation_id=conversation_id,
|
||||
query="query",
|
||||
message={"role": "user", "content": "query"},
|
||||
answer="answer",
|
||||
message_unit_price=Decimal("0.0001"),
|
||||
answer_unit_price=Decimal("0.0001"),
|
||||
currency="USD",
|
||||
from_source=ConversationFromSource.API,
|
||||
)
|
||||
message.id = message_id
|
||||
message._inputs = {}
|
||||
message.workflow_run_id = workflow_run_id
|
||||
return message
|
||||
|
||||
|
||||
class TestWorkflowRunServiceInitialization:
|
||||
@@ -48,59 +67,51 @@ class TestWorkflowRunServiceInitialization:
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
repository_factory_mocks: tuple[MagicMock, MagicMock, Any],
|
||||
sqlite_engine: Engine,
|
||||
) -> None:
|
||||
session_factory = MagicMock(name="session_factory")
|
||||
sessionmaker_mock = MagicMock(return_value=session_factory)
|
||||
monkeypatch.setattr(service_module, "sessionmaker", sessionmaker_mock)
|
||||
monkeypatch.setattr(service_module, "db", SimpleNamespace(engine="db-engine"))
|
||||
monkeypatch.setattr(service_module, "db", SimpleNamespace(engine=sqlite_engine))
|
||||
|
||||
service = WorkflowRunService()
|
||||
|
||||
sessionmaker_mock.assert_called_once_with(bind="db-engine", expire_on_commit=False)
|
||||
assert service._session_factory is session_factory
|
||||
assert isinstance(service._session_factory, sessionmaker)
|
||||
assert service._session_factory.kw["bind"] is sqlite_engine
|
||||
assert service._session_factory.kw["expire_on_commit"] is False
|
||||
|
||||
def test___init___should_create_sessionmaker_when_engine_is_provided(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
repository_factory_mocks: tuple[MagicMock, MagicMock, Any],
|
||||
sqlite_engine: Engine,
|
||||
) -> None:
|
||||
class FakeEngine:
|
||||
pass
|
||||
service = WorkflowRunService(session_factory=sqlite_engine)
|
||||
|
||||
session_factory = MagicMock(name="session_factory")
|
||||
sessionmaker_mock = MagicMock(return_value=session_factory)
|
||||
monkeypatch.setattr(service_module, "Engine", FakeEngine)
|
||||
monkeypatch.setattr(service_module, "sessionmaker", sessionmaker_mock)
|
||||
engine = cast(Engine, FakeEngine())
|
||||
|
||||
service = WorkflowRunService(session_factory=engine)
|
||||
|
||||
sessionmaker_mock.assert_called_once_with(bind=engine, expire_on_commit=False)
|
||||
assert service._session_factory is session_factory
|
||||
assert isinstance(service._session_factory, sessionmaker)
|
||||
assert service._session_factory.kw["bind"] is sqlite_engine
|
||||
assert service._session_factory.kw["expire_on_commit"] is False
|
||||
|
||||
def test___init___should_keep_provided_sessionmaker_and_create_repositories(
|
||||
self,
|
||||
repository_factory_mocks: tuple[MagicMock, MagicMock, Any],
|
||||
sqlalchemy_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
node_repo, workflow_run_repo, factory = repository_factory_mocks
|
||||
session_factory = MagicMock(name="session_factory")
|
||||
|
||||
service = WorkflowRunService(session_factory=session_factory)
|
||||
service = WorkflowRunService(session_factory=sqlalchemy_session_factory)
|
||||
|
||||
assert service._session_factory is session_factory
|
||||
assert service._session_factory is sqlalchemy_session_factory
|
||||
assert service._node_execution_service_repo is node_repo
|
||||
assert service._workflow_run_repo is workflow_run_repo
|
||||
factory.create_api_workflow_node_execution_repository.assert_called_once_with(session_factory)
|
||||
factory.create_api_workflow_run_repository.assert_called_once_with(session_factory)
|
||||
factory.create_api_workflow_node_execution_repository.assert_called_once_with(sqlalchemy_session_factory)
|
||||
factory.create_api_workflow_run_repository.assert_called_once_with(sqlalchemy_session_factory)
|
||||
|
||||
|
||||
class TestWorkflowRunServiceQueries:
|
||||
def test_get_paginate_workflow_runs_should_forward_filters_and_parse_limit(
|
||||
self,
|
||||
repository_factory_mocks: tuple[MagicMock, MagicMock, Any],
|
||||
sqlalchemy_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
_, workflow_run_repo, _ = repository_factory_mocks
|
||||
service = WorkflowRunService(session_factory=MagicMock(name="session_factory"))
|
||||
service = WorkflowRunService(session_factory=sqlalchemy_session_factory)
|
||||
app_model = _app_model(tenant_id="tenant-1", id="app-1")
|
||||
expected = MagicMock(name="pagination")
|
||||
workflow_run_repo.get_paginated_workflow_runs.return_value = expected
|
||||
@@ -122,20 +133,24 @@ class TestWorkflowRunServiceQueries:
|
||||
status="succeeded",
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Message,)], indirect=True)
|
||||
def test_get_paginate_advanced_chat_workflow_runs_should_attach_message_fields_when_message_exists(
|
||||
self,
|
||||
repository_factory_mocks: tuple[MagicMock, MagicMock, Any],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlalchemy_session_factory: sessionmaker[Session],
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
message = SimpleNamespace(id="msg-1", conversation_id="conv-1", workflow_run_id="run-1")
|
||||
session_factory, session = _fake_session_factory_returning_messages([message])
|
||||
service = WorkflowRunService(session_factory=session_factory)
|
||||
service = WorkflowRunService(session_factory=sqlalchemy_session_factory)
|
||||
app_model = _app_model(tenant_id="tenant-1", id="app-1")
|
||||
run_with_message = SimpleNamespace(id="run-1", status="running")
|
||||
run_without_message = SimpleNamespace(id="run-2", status="succeeded")
|
||||
pagination = SimpleNamespace(data=[run_with_message, run_without_message])
|
||||
monkeypatch.setattr(service, "get_paginate_workflow_runs", MagicMock(return_value=pagination))
|
||||
|
||||
sqlite_session.add(_message(message_id="msg-1", conversation_id="conv-1", workflow_run_id="run-1"))
|
||||
sqlite_session.commit()
|
||||
|
||||
result = service.get_paginate_advanced_chat_workflow_runs(app_model=app_model, args={"limit": "2"})
|
||||
|
||||
assert result is pagination
|
||||
@@ -145,39 +160,49 @@ class TestWorkflowRunServiceQueries:
|
||||
assert result.data[0].status == "running"
|
||||
assert not hasattr(result.data[1], "message_id")
|
||||
assert result.data[1].id == "run-2"
|
||||
# Messages are batch-loaded in a single query, not one per run.
|
||||
session_factory.assert_called_once_with()
|
||||
session.scalars.assert_called_once()
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Message,)], indirect=True)
|
||||
def test_get_paginate_advanced_chat_workflow_runs_batch_loads_messages_without_n_plus_one(
|
||||
self,
|
||||
repository_factory_mocks: tuple[MagicMock, MagicMock, Any],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlalchemy_session_factory: sessionmaker[Session],
|
||||
sqlite_session: Session,
|
||||
) -> None:
|
||||
"""Messages must load with a constant query count regardless of run count.
|
||||
|
||||
Previously the deprecated WorkflowRun.message property issued one query per
|
||||
run (N+1); they are now batch-loaded in a single query.
|
||||
"""
|
||||
session_factory, session = _fake_session_factory_returning_messages([])
|
||||
service = WorkflowRunService(session_factory=session_factory)
|
||||
service = WorkflowRunService(session_factory=sqlalchemy_session_factory)
|
||||
app_model = _app_model(tenant_id="tenant-1", id="app-1")
|
||||
runs = [SimpleNamespace(id=f"run-{i}", status="succeeded") for i in range(5)]
|
||||
pagination = SimpleNamespace(data=runs)
|
||||
monkeypatch.setattr(service, "get_paginate_workflow_runs", MagicMock(return_value=pagination))
|
||||
|
||||
service.get_paginate_advanced_chat_workflow_runs(app_model=app_model, args={})
|
||||
message_query_count = 0
|
||||
|
||||
# Exactly one message query for the whole page, independent of run count.
|
||||
session_factory.assert_called_once_with()
|
||||
assert session.scalars.call_count == 1
|
||||
def count_message_query(*_args: object) -> None:
|
||||
nonlocal message_query_count
|
||||
message_query_count += 1
|
||||
|
||||
engine = sqlite_session.get_bind()
|
||||
event.listen(engine, "before_cursor_execute", count_message_query)
|
||||
try:
|
||||
service.get_paginate_advanced_chat_workflow_runs(app_model=app_model, args={})
|
||||
finally:
|
||||
event.remove(engine, "before_cursor_execute", count_message_query)
|
||||
|
||||
assert all(not hasattr(run, "message_id") for run in runs)
|
||||
assert message_query_count == 1
|
||||
|
||||
def test_get_workflow_run_should_delegate_to_repository_by_tenant_and_app(
|
||||
self,
|
||||
repository_factory_mocks: tuple[MagicMock, MagicMock, Any],
|
||||
sqlalchemy_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
_, workflow_run_repo, _ = repository_factory_mocks
|
||||
service = WorkflowRunService(session_factory=MagicMock(name="session_factory"))
|
||||
service = WorkflowRunService(session_factory=sqlalchemy_session_factory)
|
||||
app_model = _app_model(tenant_id="tenant-1", id="app-1")
|
||||
expected = MagicMock(name="workflow_run")
|
||||
workflow_run_repo.get_workflow_run_by_id.return_value = expected
|
||||
@@ -194,9 +219,10 @@ class TestWorkflowRunServiceQueries:
|
||||
def test_get_workflow_runs_count_should_forward_optional_filters(
|
||||
self,
|
||||
repository_factory_mocks: tuple[MagicMock, MagicMock, Any],
|
||||
sqlalchemy_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
_, workflow_run_repo, _ = repository_factory_mocks
|
||||
service = WorkflowRunService(session_factory=MagicMock(name="session_factory"))
|
||||
service = WorkflowRunService(session_factory=sqlalchemy_session_factory)
|
||||
app_model = _app_model(tenant_id="tenant-1", id="app-1")
|
||||
expected = {"total": 3, "succeeded": 2}
|
||||
workflow_run_repo.get_workflow_runs_count.return_value = expected
|
||||
@@ -221,8 +247,9 @@ class TestWorkflowRunServiceQueries:
|
||||
self,
|
||||
repository_factory_mocks: tuple[MagicMock, MagicMock, Any],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlalchemy_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
service = WorkflowRunService(session_factory=MagicMock(name="session_factory"))
|
||||
service = WorkflowRunService(session_factory=sqlalchemy_session_factory)
|
||||
monkeypatch.setattr(service, "get_workflow_run", MagicMock(return_value=None))
|
||||
app_model = _app_model(id="app-1")
|
||||
user = _account(current_tenant_id="tenant-1")
|
||||
@@ -235,9 +262,10 @@ class TestWorkflowRunServiceQueries:
|
||||
self,
|
||||
repository_factory_mocks: tuple[MagicMock, MagicMock, Any],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlalchemy_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
node_repo, _, _ = repository_factory_mocks
|
||||
service = WorkflowRunService(session_factory=MagicMock(name="session_factory"))
|
||||
service = WorkflowRunService(session_factory=sqlalchemy_session_factory)
|
||||
monkeypatch.setattr(service, "get_workflow_run", MagicMock(return_value=SimpleNamespace(id="run-1")))
|
||||
|
||||
class FakeEndUser:
|
||||
@@ -267,9 +295,10 @@ class TestWorkflowRunServiceQueries:
|
||||
self,
|
||||
repository_factory_mocks: tuple[MagicMock, MagicMock, Any],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlalchemy_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
node_repo, _, _ = repository_factory_mocks
|
||||
service = WorkflowRunService(session_factory=MagicMock(name="session_factory"))
|
||||
service = WorkflowRunService(session_factory=sqlalchemy_session_factory)
|
||||
monkeypatch.setattr(service, "get_workflow_run", MagicMock(return_value=SimpleNamespace(id="run-1")))
|
||||
app_model = _app_model(id="app-1")
|
||||
user = _account(current_tenant_id="tenant-account")
|
||||
@@ -293,8 +322,9 @@ class TestWorkflowRunServiceQueries:
|
||||
self,
|
||||
repository_factory_mocks: tuple[MagicMock, MagicMock, Any],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
sqlalchemy_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
service = WorkflowRunService(session_factory=MagicMock(name="session_factory"))
|
||||
service = WorkflowRunService(session_factory=sqlalchemy_session_factory)
|
||||
monkeypatch.setattr(service, "get_workflow_run", MagicMock(return_value=SimpleNamespace(id="run-1")))
|
||||
app_model = _app_model(id="app-1")
|
||||
user = _account(current_tenant_id=None)
|
||||
|
||||
@@ -1,32 +1,73 @@
|
||||
"""Simplified unit tests for DraftVarLoader focusing on core functionality."""
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import Engine
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.workflow.file_reference import build_file_reference
|
||||
from extensions.storage.storage_type import StorageType
|
||||
from graphon.file import File, FileTransferMethod, FileType
|
||||
from graphon.variables.segments import ObjectSegment, StringSegment
|
||||
from graphon.variables.types import SegmentType
|
||||
from models.enums import CreatorUserRole
|
||||
from models.model import UploadFile
|
||||
from models.workflow import WorkflowDraftVariable, WorkflowDraftVariableFile
|
||||
from services.workflow_draft_variable_service import DraftVarLoader
|
||||
|
||||
|
||||
def _persist_offloaded_variable(
|
||||
sqlite_session: Session,
|
||||
*,
|
||||
node_id: str,
|
||||
name: str,
|
||||
) -> WorkflowDraftVariable:
|
||||
upload_file = UploadFile(
|
||||
tenant_id="test-tenant-id",
|
||||
storage_type=StorageType.LOCAL,
|
||||
key=f"storage/key/{name}.txt",
|
||||
name=f"{name}.txt",
|
||||
size=10,
|
||||
extension=".txt",
|
||||
mime_type="text/plain",
|
||||
created_by_role=CreatorUserRole.ACCOUNT,
|
||||
created_by="test-user-id",
|
||||
created_at=datetime(2025, 1, 1),
|
||||
used=True,
|
||||
)
|
||||
variable_file = WorkflowDraftVariableFile(
|
||||
tenant_id="test-tenant-id",
|
||||
app_id="test-app-id",
|
||||
user_id="test-user-id",
|
||||
upload_file_id=upload_file.id,
|
||||
size=10,
|
||||
length=None,
|
||||
value_type=SegmentType.STRING,
|
||||
)
|
||||
draft_variable = WorkflowDraftVariable.new_node_variable(
|
||||
app_id="test-app-id",
|
||||
user_id="test-user-id",
|
||||
node_id=node_id,
|
||||
name=name,
|
||||
value=StringSegment(value="truncated"),
|
||||
node_execution_id=f"execution-{node_id}",
|
||||
file_id=variable_file.id,
|
||||
)
|
||||
sqlite_session.add_all([upload_file, variable_file, draft_variable])
|
||||
return draft_variable
|
||||
|
||||
|
||||
class TestDraftVarLoaderSimple:
|
||||
"""Simplified unit tests for DraftVarLoader core methods."""
|
||||
|
||||
@pytest.fixture
|
||||
def mock_engine(self) -> Engine:
|
||||
return Mock(spec=Engine)
|
||||
|
||||
@pytest.fixture
|
||||
def draft_var_loader(self, mock_engine):
|
||||
def draft_var_loader(self, sqlite_engine: Engine):
|
||||
"""Create DraftVarLoader instance for testing."""
|
||||
return DraftVarLoader(
|
||||
engine=mock_engine,
|
||||
engine=sqlite_engine,
|
||||
app_id="test-app-id",
|
||||
tenant_id="test-tenant-id",
|
||||
user_id="test-user-id",
|
||||
@@ -205,131 +246,109 @@ class TestDraftVarLoaderSimple:
|
||||
assert variable.value == rebuilt_file
|
||||
rebuild_file.assert_called_once_with(file_mapping=raw_file, tenant_id="tenant-1")
|
||||
|
||||
def test_load_variables_with_offloaded_variables_unit(self, draft_var_loader):
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(WorkflowDraftVariable, WorkflowDraftVariableFile, UploadFile)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_load_variables_with_offloaded_variables_unit(
|
||||
self,
|
||||
draft_var_loader: DraftVarLoader,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
"""Test load_variables method with mix of regular and offloaded variables."""
|
||||
selectors = [["node1", "regular_var"], ["node2", "offloaded_var"]]
|
||||
|
||||
# Mock regular variable
|
||||
regular_draft_var = Mock(spec=WorkflowDraftVariable)
|
||||
regular_draft_var.is_truncated.return_value = False
|
||||
regular_draft_var.node_id = "node1"
|
||||
regular_draft_var.name = "regular_var"
|
||||
regular_draft_var.get_value.return_value = StringSegment(value="regular_value")
|
||||
regular_draft_var.get_selector.return_value = ["node1", "regular_var"]
|
||||
regular_draft_var.id = "regular-var-id"
|
||||
regular_draft_var = WorkflowDraftVariable.new_node_variable(
|
||||
app_id="test-app-id",
|
||||
user_id="test-user-id",
|
||||
node_id="node1",
|
||||
name="regular_var",
|
||||
value=StringSegment(value="regular_value"),
|
||||
node_execution_id="execution-node1",
|
||||
)
|
||||
regular_draft_var.description = "regular description"
|
||||
offloaded_draft_var = _persist_offloaded_variable(
|
||||
sqlite_session,
|
||||
node_id="node2",
|
||||
name="offloaded_var",
|
||||
)
|
||||
distractor = WorkflowDraftVariable.new_node_variable(
|
||||
app_id="test-app-id",
|
||||
user_id="another-user",
|
||||
node_id="node1",
|
||||
name="regular_var",
|
||||
value=StringSegment(value="wrong user"),
|
||||
node_execution_id="execution-distractor",
|
||||
)
|
||||
sqlite_session.add_all([regular_draft_var, distractor])
|
||||
sqlite_session.commit()
|
||||
|
||||
# Mock offloaded variable
|
||||
upload_file = Mock(spec=UploadFile)
|
||||
upload_file.key = "storage/key/offloaded.txt"
|
||||
offloaded_variable = Mock()
|
||||
offloaded_variable.id = offloaded_draft_var.id
|
||||
offloaded_variable.selector = ["node2", "offloaded_var"]
|
||||
|
||||
variable_file = Mock(spec=WorkflowDraftVariableFile)
|
||||
variable_file.value_type = SegmentType.STRING
|
||||
variable_file.upload_file = upload_file
|
||||
with (
|
||||
patch("services.workflow_draft_variable_service.StorageKeyLoader"),
|
||||
patch.object(
|
||||
draft_var_loader,
|
||||
"_load_offloaded_variable",
|
||||
return_value=(("node2", "offloaded_var"), offloaded_variable),
|
||||
) as load_offloaded,
|
||||
patch("services.workflow_draft_variable_service.ThreadPoolExecutor") as executor_cls,
|
||||
):
|
||||
executor = executor_cls.return_value.__enter__.return_value
|
||||
executor.map.side_effect = lambda function, values: [function(value) for value in values]
|
||||
|
||||
offloaded_draft_var = Mock(spec=WorkflowDraftVariable)
|
||||
offloaded_draft_var.is_truncated.return_value = True
|
||||
offloaded_draft_var.node_id = "node2"
|
||||
offloaded_draft_var.name = "offloaded_var"
|
||||
offloaded_draft_var.get_selector.return_value = ["node2", "offloaded_var"]
|
||||
offloaded_draft_var.variable_file = variable_file
|
||||
offloaded_draft_var.id = "offloaded-var-id"
|
||||
offloaded_draft_var.description = "offloaded description"
|
||||
result = draft_var_loader.load_variables(selectors)
|
||||
|
||||
draft_vars = [regular_draft_var, offloaded_draft_var]
|
||||
assert {variable.id for variable in result} == {regular_draft_var.id, offloaded_draft_var.id}
|
||||
load_offloaded.assert_called_once()
|
||||
loaded_offloaded = load_offloaded.call_args.args[0]
|
||||
assert isinstance(loaded_offloaded, WorkflowDraftVariable)
|
||||
assert loaded_offloaded.id == offloaded_draft_var.id
|
||||
assert loaded_offloaded.variable_file is not None
|
||||
assert loaded_offloaded.variable_file.upload_file is not None
|
||||
assert loaded_offloaded.variable_file.upload_file.key == "storage/key/offloaded_var.txt"
|
||||
|
||||
with patch("services.workflow_draft_variable_service.Session") as mock_session_cls:
|
||||
mock_session = Mock()
|
||||
mock_session_cls.return_value.__enter__.return_value = mock_session
|
||||
|
||||
mock_service = Mock()
|
||||
mock_service.get_draft_variables_by_selectors.return_value = draft_vars
|
||||
|
||||
with patch(
|
||||
"services.workflow_draft_variable_service.WorkflowDraftVariableService", return_value=mock_service
|
||||
):
|
||||
with patch("services.workflow_draft_variable_service.StorageKeyLoader"):
|
||||
with patch("factories.variable_factory.segment_to_variable") as mock_segment_to_variable:
|
||||
# Mock regular variable creation
|
||||
regular_variable = Mock()
|
||||
regular_variable.selector = ["node1", "regular_var"]
|
||||
|
||||
# Mock offloaded variable creation
|
||||
offloaded_variable = Mock()
|
||||
offloaded_variable.selector = ["node2", "offloaded_var"]
|
||||
|
||||
mock_segment_to_variable.return_value = regular_variable
|
||||
|
||||
with patch("services.workflow_draft_variable_service.storage") as mock_storage:
|
||||
mock_storage.load.return_value = b"offloaded_content"
|
||||
|
||||
with patch.object(draft_var_loader, "_load_offloaded_variable") as mock_load_offloaded:
|
||||
mock_load_offloaded.return_value = (("node2", "offloaded_var"), offloaded_variable)
|
||||
|
||||
with patch("concurrent.futures.ThreadPoolExecutor") as mock_executor_cls:
|
||||
mock_executor = Mock()
|
||||
mock_executor_cls.return_value.__enter__.return_value = mock_executor
|
||||
mock_executor.map.return_value = [(("node2", "offloaded_var"), offloaded_variable)]
|
||||
|
||||
# Execute the method
|
||||
result = draft_var_loader.load_variables(selectors)
|
||||
|
||||
# Verify results
|
||||
assert len(result) == 2
|
||||
|
||||
# Verify service method was called
|
||||
mock_service.get_draft_variables_by_selectors.assert_called_once_with(
|
||||
draft_var_loader._app_id,
|
||||
selectors,
|
||||
user_id=draft_var_loader._user_id,
|
||||
)
|
||||
|
||||
# Verify offloaded variable loading was called
|
||||
mock_load_offloaded.assert_called_once_with(offloaded_draft_var)
|
||||
|
||||
def test_load_variables_all_offloaded_variables_unit(self, draft_var_loader):
|
||||
@pytest.mark.parametrize(
|
||||
"sqlite_session",
|
||||
[(WorkflowDraftVariable, WorkflowDraftVariableFile, UploadFile)],
|
||||
indirect=True,
|
||||
)
|
||||
def test_load_variables_all_offloaded_variables_unit(
|
||||
self,
|
||||
draft_var_loader: DraftVarLoader,
|
||||
sqlite_session: Session,
|
||||
):
|
||||
"""Test load_variables method with only offloaded variables."""
|
||||
selectors = [["node1", "offloaded_var1"], ["node2", "offloaded_var2"]]
|
||||
offloaded_var1 = _persist_offloaded_variable(
|
||||
sqlite_session,
|
||||
node_id="node1",
|
||||
name="offloaded_var1",
|
||||
)
|
||||
offloaded_var2 = _persist_offloaded_variable(
|
||||
sqlite_session,
|
||||
node_id="node2",
|
||||
name="offloaded_var2",
|
||||
)
|
||||
sqlite_session.commit()
|
||||
|
||||
# Mock first offloaded variable
|
||||
offloaded_var1 = Mock(spec=WorkflowDraftVariable)
|
||||
offloaded_var1.is_truncated.return_value = True
|
||||
offloaded_var1.node_id = "node1"
|
||||
offloaded_var1.name = "offloaded_var1"
|
||||
with (
|
||||
patch("services.workflow_draft_variable_service.StorageKeyLoader"),
|
||||
patch("services.workflow_draft_variable_service.ThreadPoolExecutor") as executor_cls,
|
||||
):
|
||||
executor = executor_cls.return_value.__enter__.return_value
|
||||
executor.map.return_value = [
|
||||
(("node1", "offloaded_var1"), Mock()),
|
||||
(("node2", "offloaded_var2"), Mock()),
|
||||
]
|
||||
|
||||
# Mock second offloaded variable
|
||||
offloaded_var2 = Mock(spec=WorkflowDraftVariable)
|
||||
offloaded_var2.is_truncated.return_value = True
|
||||
offloaded_var2.node_id = "node2"
|
||||
offloaded_var2.name = "offloaded_var2"
|
||||
result = draft_var_loader.load_variables(selectors)
|
||||
|
||||
draft_vars = [offloaded_var1, offloaded_var2]
|
||||
|
||||
with patch("services.workflow_draft_variable_service.Session") as mock_session_cls:
|
||||
mock_session = Mock()
|
||||
mock_session_cls.return_value.__enter__.return_value = mock_session
|
||||
|
||||
mock_service = Mock()
|
||||
mock_service.get_draft_variables_by_selectors.return_value = draft_vars
|
||||
|
||||
with patch(
|
||||
"services.workflow_draft_variable_service.WorkflowDraftVariableService", return_value=mock_service
|
||||
):
|
||||
with patch("services.workflow_draft_variable_service.StorageKeyLoader"):
|
||||
with patch("services.workflow_draft_variable_service.ThreadPoolExecutor") as mock_executor_cls:
|
||||
mock_executor = Mock()
|
||||
mock_executor_cls.return_value.__enter__.return_value = mock_executor
|
||||
mock_executor.map.return_value = [
|
||||
(("node1", "offloaded_var1"), Mock()),
|
||||
(("node2", "offloaded_var2"), Mock()),
|
||||
]
|
||||
|
||||
# Execute the method
|
||||
result = draft_var_loader.load_variables(selectors)
|
||||
|
||||
# Verify results - since we have only offloaded variables, should have 2 results
|
||||
assert len(result) == 2
|
||||
|
||||
# Verify ThreadPoolExecutor was used
|
||||
mock_executor_cls.assert_called_once_with(max_workers=10)
|
||||
mock_executor.map.assert_called_once()
|
||||
assert len(result) == 2
|
||||
executor_cls.assert_called_once_with(max_workers=10)
|
||||
executor.map.assert_called_once()
|
||||
loaded_draft_vars = executor.map.call_args.args[1]
|
||||
assert {variable.id for variable in loaded_draft_vars} == {offloaded_var1.id, offloaded_var2.id}
|
||||
assert all(variable.variable_file.upload_file is not None for variable in loaded_draft_vars)
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
"""Enterprise license gating performed by the global ``before_request`` hook."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from flask import Blueprint, Flask
|
||||
from flask_restx import Resource
|
||||
|
||||
from app_factory import create_flask_app_with_configs
|
||||
from libs.external_api import ExternalApi
|
||||
from services.feature_service import LicenseStatus
|
||||
|
||||
INVALID_STATUSES = [LicenseStatus.INACTIVE, LicenseStatus.EXPIRED, LicenseStatus.LOST]
|
||||
VALID_STATUSES = [LicenseStatus.ACTIVE, LicenseStatus.EXPIRING]
|
||||
|
||||
|
||||
def _license(status: LicenseStatus | None):
|
||||
return patch("app_factory.EnterpriseService.get_cached_license_status", return_value=status)
|
||||
|
||||
|
||||
def _enterprise(enabled: bool = True):
|
||||
return patch("app_factory.dify_config.ENTERPRISE_ENABLED", enabled)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def gated_app() -> Flask:
|
||||
app = create_flask_app_with_configs()
|
||||
|
||||
@app.route("/v1/chat-messages", methods=["POST"])
|
||||
def service_api_route():
|
||||
return {"surface": "service_api"}
|
||||
|
||||
@app.route("/v1/")
|
||||
def service_api_index_route():
|
||||
return {"surface": "service_api_index"}
|
||||
|
||||
@app.route("/mcp/server/<server_code>/mcp", methods=["POST"])
|
||||
def mcp_route(server_code: str):
|
||||
return {"surface": "mcp"}
|
||||
|
||||
@app.route("/triggers/webhook/<webhook_id>", methods=["POST"])
|
||||
def trigger_route(webhook_id: str):
|
||||
return {"surface": "triggers"}
|
||||
|
||||
@app.route("/console/api/apps")
|
||||
def console_route():
|
||||
return {"surface": "console"}
|
||||
|
||||
@app.route("/console/api/login", methods=["POST"])
|
||||
def console_bootstrap_route():
|
||||
return {"surface": "console_bootstrap"}
|
||||
|
||||
@app.route("/api/messages")
|
||||
def webapp_route():
|
||||
return {"surface": "webapp"}
|
||||
|
||||
@app.route("/api/system-features")
|
||||
def webapp_bootstrap_route():
|
||||
return {"surface": "webapp_bootstrap"}
|
||||
|
||||
@app.route("/health")
|
||||
def health_route():
|
||||
return {"surface": "health"}
|
||||
|
||||
@app.route("/inner/api/rbac/check-access", methods=["POST"])
|
||||
def inner_api_route():
|
||||
return {"surface": "inner_api"}
|
||||
|
||||
@app.route("/files/upload/for-plugin", methods=["POST"])
|
||||
def files_route():
|
||||
return {"surface": "files"}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
class TestServiceApiLicenseGate:
|
||||
"""/v1 is a bearer-token surface, so it is gated with an opaque 403."""
|
||||
|
||||
@pytest.mark.parametrize("status", INVALID_STATUSES)
|
||||
def test_blocks_when_license_invalid(self, gated_app: Flask, status: LicenseStatus):
|
||||
with _enterprise(), _license(status):
|
||||
response = gated_app.test_client().post("/v1/chat-messages")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_block_response_carries_machine_readable_marker(self, gated_app: Flask):
|
||||
with _enterprise(), _license(LicenseStatus.EXPIRED):
|
||||
response = gated_app.test_client().post("/v1/chat-messages")
|
||||
|
||||
assert b"license_required" in response.data
|
||||
|
||||
def test_block_response_does_not_leak_license_status(self, gated_app: Flask):
|
||||
with _enterprise(), _license(LicenseStatus.EXPIRED):
|
||||
response = gated_app.test_client().post("/v1/chat-messages")
|
||||
|
||||
assert b"expired" not in response.data.lower()
|
||||
|
||||
def test_blocks_when_license_status_unavailable(self, gated_app: Flask):
|
||||
with _enterprise(), _license(None):
|
||||
response = gated_app.test_client().post("/v1/chat-messages")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_blocks_when_license_lookup_raises(self, gated_app: Flask):
|
||||
lookup_failed = patch(
|
||||
"app_factory.EnterpriseService.get_cached_license_status",
|
||||
side_effect=RuntimeError("enterprise api unreachable"),
|
||||
)
|
||||
with _enterprise(), lookup_failed:
|
||||
response = gated_app.test_client().post("/v1/chat-messages")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_blocks_index_route(self, gated_app: Flask):
|
||||
"""/v1 has no sign-in page to bootstrap, so nothing on it is exempt."""
|
||||
with _enterprise(), _license(LicenseStatus.EXPIRED):
|
||||
response = gated_app.test_client().get("/v1/")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
@pytest.mark.parametrize("status", VALID_STATUSES)
|
||||
def test_allows_when_license_valid(self, gated_app: Flask, status: LicenseStatus):
|
||||
with _enterprise(), _license(status):
|
||||
response = gated_app.test_client().post("/v1/chat-messages")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_allows_unclassified_status(self, gated_app: Flask):
|
||||
"""LicenseStatus.NONE is not in the blocked set — parity with console/webapp."""
|
||||
with _enterprise(), _license(LicenseStatus.NONE):
|
||||
response = gated_app.test_client().post("/v1/chat-messages")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.parametrize("status", INVALID_STATUSES)
|
||||
def test_does_not_gate_community_edition(self, gated_app: Flask, status: LicenseStatus):
|
||||
with _enterprise(False), _license(status):
|
||||
response = gated_app.test_client().post("/v1/chat-messages")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestMcpLicenseGate:
|
||||
"""/mcp invokes apps for external MCP clients, so it is gated like the Service API."""
|
||||
|
||||
@pytest.mark.parametrize("status", INVALID_STATUSES)
|
||||
def test_blocks_when_license_invalid(self, gated_app: Flask, status: LicenseStatus):
|
||||
with _enterprise(), _license(status):
|
||||
response = gated_app.test_client().post("/mcp/server/srv-code/mcp")
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
def test_block_response_carries_machine_readable_marker(self, gated_app: Flask):
|
||||
with _enterprise(), _license(LicenseStatus.EXPIRED):
|
||||
response = gated_app.test_client().post("/mcp/server/srv-code/mcp")
|
||||
|
||||
assert b"license_required" in response.data
|
||||
|
||||
@pytest.mark.parametrize("status", VALID_STATUSES)
|
||||
def test_allows_when_license_valid(self, gated_app: Flask, status: LicenseStatus):
|
||||
with _enterprise(), _license(status):
|
||||
response = gated_app.test_client().post("/mcp/server/srv-code/mcp")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.parametrize("status", INVALID_STATUSES)
|
||||
def test_does_not_gate_community_edition(self, gated_app: Flask, status: LicenseStatus):
|
||||
with _enterprise(False), _license(status):
|
||||
response = gated_app.test_client().post("/mcp/server/srv-code/mcp")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestTriggerLicenseGate:
|
||||
"""Inbound webhooks are refused so senders retry, rather than dropping events."""
|
||||
|
||||
@pytest.mark.parametrize("status", INVALID_STATUSES)
|
||||
def test_blocks_when_license_invalid(self, gated_app: Flask, status: LicenseStatus):
|
||||
with _enterprise(), _license(status):
|
||||
response = gated_app.test_client().post("/triggers/webhook/hook-id")
|
||||
|
||||
assert response.status_code == 503
|
||||
|
||||
def test_block_response_carries_machine_readable_marker(self, gated_app: Flask):
|
||||
with _enterprise(), _license(LicenseStatus.EXPIRED):
|
||||
response = gated_app.test_client().post("/triggers/webhook/hook-id")
|
||||
|
||||
assert b"license_required" in response.data
|
||||
|
||||
@pytest.mark.parametrize("status", VALID_STATUSES)
|
||||
def test_allows_when_license_valid(self, gated_app: Flask, status: LicenseStatus):
|
||||
with _enterprise(), _license(status):
|
||||
response = gated_app.test_client().post("/triggers/webhook/hook-id")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.parametrize("status", INVALID_STATUSES)
|
||||
def test_does_not_gate_community_edition(self, gated_app: Flask, status: LicenseStatus):
|
||||
with _enterprise(False), _license(status):
|
||||
response = gated_app.test_client().post("/triggers/webhook/hook-id")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestGateThroughRealErrorHandlers:
|
||||
"""Gate errors must survive each blueprint's error handling: flask-restx vs plain Flask."""
|
||||
|
||||
@pytest.fixture
|
||||
def wired_app(self) -> Flask:
|
||||
app = create_flask_app_with_configs()
|
||||
|
||||
service_api_bp = Blueprint("service_api_test", __name__, url_prefix="/v1")
|
||||
api = ExternalApi(service_api_bp)
|
||||
|
||||
@api.route("/chat-messages")
|
||||
class ChatMessages(Resource):
|
||||
def post(self):
|
||||
return {"surface": "service_api"}
|
||||
|
||||
app.register_blueprint(service_api_bp)
|
||||
|
||||
trigger_bp = Blueprint("trigger_test", __name__, url_prefix="/triggers")
|
||||
|
||||
@trigger_bp.route("/webhook/<webhook_id>", methods=["POST"])
|
||||
def webhook_route(webhook_id: str):
|
||||
return {"surface": "triggers"}
|
||||
|
||||
app.register_blueprint(trigger_bp)
|
||||
return app
|
||||
|
||||
def test_service_api_block_is_json_with_license_marker(self, wired_app: Flask):
|
||||
with _enterprise(), _license(LicenseStatus.EXPIRED):
|
||||
response = wired_app.test_client().post("/v1/chat-messages")
|
||||
|
||||
assert response.status_code == 403
|
||||
body = response.get_json()
|
||||
assert body["message"] == "license_required"
|
||||
assert body["status"] == 403
|
||||
|
||||
def test_service_api_block_does_not_clear_cookies(self, wired_app: Flask):
|
||||
"""Force-logout cookie clearing belongs to the cookie-authed surfaces only."""
|
||||
with _enterprise(), _license(LicenseStatus.EXPIRED):
|
||||
response = wired_app.test_client().post("/v1/chat-messages")
|
||||
|
||||
assert response.headers.getlist("Set-Cookie") == []
|
||||
|
||||
def test_trigger_block_survives_plain_blueprint_handling(self, wired_app: Flask):
|
||||
with _enterprise(), _license(LicenseStatus.EXPIRED):
|
||||
response = wired_app.test_client().post("/triggers/webhook/hook-id")
|
||||
|
||||
assert response.status_code == 503
|
||||
assert b"license_required" in response.data
|
||||
|
||||
def test_surfaces_are_reachable_when_license_valid(self, wired_app: Flask):
|
||||
with _enterprise(), _license(LicenseStatus.ACTIVE):
|
||||
service_api = wired_app.test_client().post("/v1/chat-messages")
|
||||
triggers = wired_app.test_client().post("/triggers/webhook/hook-id")
|
||||
|
||||
assert service_api.status_code == 200
|
||||
assert triggers.status_code == 200
|
||||
|
||||
|
||||
class TestUngatedSurfaces:
|
||||
"""Surfaces that must stay reachable while the license is invalid."""
|
||||
|
||||
def test_inner_api_is_not_gated(self, gated_app: Flask):
|
||||
"""dify-enterprise control plane — gating it could block license recovery itself."""
|
||||
with _enterprise(), _license(LicenseStatus.EXPIRED):
|
||||
response = gated_app.test_client().post("/inner/api/rbac/check-access")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_files_data_plane_is_not_gated(self, gated_app: Flask):
|
||||
"""Signed file URLs are fetched by the plugin daemon and by LLM vendors."""
|
||||
with _enterprise(), _license(LicenseStatus.EXPIRED):
|
||||
response = gated_app.test_client().post("/files/upload/for-plugin")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
|
||||
class TestSessionSurfaceLicenseGate:
|
||||
"""Console and webapp are cookie-authed, so they keep force-logout 401 semantics."""
|
||||
|
||||
@pytest.mark.parametrize("status", INVALID_STATUSES)
|
||||
def test_blocks_console_with_force_logout(self, gated_app: Flask, status: LicenseStatus):
|
||||
with _enterprise(), _license(status):
|
||||
response = gated_app.test_client().get("/console/api/apps")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.parametrize("status", INVALID_STATUSES)
|
||||
def test_blocks_webapp_with_force_logout(self, gated_app: Flask, status: LicenseStatus):
|
||||
with _enterprise(), _license(status):
|
||||
response = gated_app.test_client().get("/api/messages")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
def test_console_bootstrap_route_stays_reachable(self, gated_app: Flask):
|
||||
with _enterprise(), _license(LicenseStatus.EXPIRED):
|
||||
response = gated_app.test_client().post("/console/api/login")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_webapp_bootstrap_route_stays_reachable(self, gated_app: Flask):
|
||||
with _enterprise(), _license(LicenseStatus.EXPIRED):
|
||||
response = gated_app.test_client().get("/api/system-features")
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_health_route_is_never_gated(self, gated_app: Flask):
|
||||
with _enterprise(), _license(LicenseStatus.EXPIRED):
|
||||
response = gated_app.test_client().get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -0,0 +1,83 @@
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from threading import Barrier
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine, inspect, text
|
||||
from sqlalchemy.engine import URL, Engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import QueuePool
|
||||
|
||||
import core.db.session_factory as session_factory_module
|
||||
from models.account import Account
|
||||
from models.base import TypeBase
|
||||
from models.model import ExporleBanner
|
||||
|
||||
|
||||
def test_sqlite_session_contains_the_full_registered_schema(sqlite_session: Session) -> None:
|
||||
table_names = set(inspect(sqlite_session.get_bind()).get_table_names())
|
||||
|
||||
assert table_names == set(TypeBase.metadata.tables)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("sqlite_session", [(Account,)], indirect=True)
|
||||
def test_sqlite_session_accepts_deferred_legacy_indirect_parameters(sqlite_session: Session) -> None:
|
||||
"""Prove legacy model parameters no longer limit the copied schema."""
|
||||
|
||||
assert inspect(sqlite_session.get_bind()).has_table(ExporleBanner.__tablename__)
|
||||
|
||||
|
||||
def test_sqlite_engine_is_a_pristine_file_copy(
|
||||
sqlite_engine: Engine,
|
||||
request: pytest.FixtureRequest,
|
||||
) -> None:
|
||||
sqlite_database_template: Path = request.getfixturevalue("_sqlite_database_template")
|
||||
assert isinstance(sqlite_engine.pool, QueuePool)
|
||||
assert sqlite_engine.url.database != str(sqlite_database_template)
|
||||
|
||||
with sqlite_engine.begin() as connection:
|
||||
connection.execute(text("CREATE TABLE per_test_mutation (value INTEGER NOT NULL)"))
|
||||
|
||||
template_engine = create_engine(URL.create("sqlite", database=str(sqlite_database_template)))
|
||||
try:
|
||||
assert not inspect(template_engine).has_table("per_test_mutation")
|
||||
finally:
|
||||
template_engine.dispose()
|
||||
|
||||
|
||||
def test_core_session_factory_uses_the_shared_sqlite_session_factory(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
assert session_factory_module.session_factory.get_session_maker() is sqlite_session_factory
|
||||
|
||||
with sqlite_session_factory.begin() as session:
|
||||
session.execute(text("CREATE TABLE global_factory_probe (value INTEGER NOT NULL)"))
|
||||
session.execute(text("INSERT INTO global_factory_probe (value) VALUES (42)"))
|
||||
|
||||
with session_factory_module.session_factory.create_session() as session:
|
||||
assert session.scalar(text("SELECT value FROM global_factory_probe")) == 42
|
||||
|
||||
|
||||
def test_sqlite_session_factory_shares_one_database_across_worker_sessions(
|
||||
sqlite_session_factory: sessionmaker[Session],
|
||||
) -> None:
|
||||
with sqlite_session_factory.begin() as session:
|
||||
session.execute(text("CREATE TABLE thread_probe (value INTEGER NOT NULL)"))
|
||||
session.execute(text("INSERT INTO thread_probe (value) VALUES (42)"))
|
||||
|
||||
worker_barrier = Barrier(2)
|
||||
|
||||
def read_value() -> tuple[int, int]:
|
||||
with sqlite_session_factory() as session:
|
||||
connection = session.connection()
|
||||
worker_barrier.wait(timeout=1)
|
||||
value = session.scalar(text("SELECT value FROM thread_probe"))
|
||||
connection_id = id(connection.connection.dbapi_connection)
|
||||
return connection_id, value
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as executor:
|
||||
futures = [executor.submit(read_value) for _ in range(2)]
|
||||
results = [future.result() for future in futures]
|
||||
|
||||
assert {value for _, value in results} == {42}
|
||||
assert len({connection_id for connection_id, _ in results}) == 2
|
||||
@@ -135,6 +135,19 @@ class TestMCPToolInvoke:
|
||||
values = {m.message.variable_name: m.message.variable_value for m in var_msgs}
|
||||
assert values == {"a": 1, "b": "x"}
|
||||
|
||||
def test_invoke_yields_json_when_structured_content_has_no_output_schema(self, orm_session: Session) -> None:
|
||||
tool = _make_mcp_tool()
|
||||
result = CallToolResult(content=[], structuredContent={"a": 1, "b": "x"})
|
||||
|
||||
with patch.object(tool, "invoke_remote_mcp_tool", return_value=result):
|
||||
messages = list(tool._invoke(session=orm_session, user_id="test_user", tool_parameters={}))
|
||||
|
||||
assert len(messages) == 1
|
||||
msg = messages[0]
|
||||
assert msg.type == ToolInvokeMessage.MessageType.JSON
|
||||
assert isinstance(msg.message, ToolInvokeMessage.JsonMessage)
|
||||
assert msg.message.json_object == {"a": 1, "b": "x"}
|
||||
|
||||
|
||||
class TestMCPToolUsageExtraction:
|
||||
"""Test usage metadata extraction from MCP tool results."""
|
||||
|
||||
Generated
+20
-20
@@ -475,7 +475,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "bce-python-sdk"
|
||||
version = "0.9.72"
|
||||
version = "0.9.76"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "crc32c" },
|
||||
@@ -483,9 +483,9 @@ dependencies = [
|
||||
{ name = "pycryptodome" },
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/32/bb/1ccb8b28bfa0802356f8588e479adb61cfd2268b37fabc6c8a805d645cd5/bce_python_sdk-0.9.72.tar.gz", hash = "sha256:d9db568698792d74db4245252d98776eae8c9c5225fc0ba86548dfc52d478fcc", size = 302207, upload-time = "2026-06-08T12:10:32.326Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0a/ed/b41906366c0e1e3a1a883e0f1bcfc927403d9697d74b1eb7e89502870218/bce_python_sdk-0.9.76.tar.gz", hash = "sha256:01c630ce8dcbf8be0563d65f18b5201eaac6bd954b3dc776040aec268f81b6ff", size = 317399, upload-time = "2026-07-24T05:12:57.005Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/3a/f84b025ff6c8ec8fe222430cf9201b513dd11ada744417649a64ad295b6d/bce_python_sdk-0.9.72-py3-none-any.whl", hash = "sha256:54a0c121134d6f183f6013d9b33dbf5b6678b0815b6d09616bbceed0202dd797", size = 417800, upload-time = "2026-06-08T12:10:30.556Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/06/4d4a26c3dfcdb29599f563329b87eefe55b02b3c2b9d8f8c3aeaa38a33a6/bce_python_sdk-0.9.76-py3-none-any.whl", hash = "sha256:e629181d060f4ed8f29749f47139bf08f1f9b0d8b15daedc956900785587e8d8", size = 435179, upload-time = "2026-07-24T05:12:55.064Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -598,16 +598,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.43.46"
|
||||
version = "1.43.56"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botocore" },
|
||||
{ name = "jmespath" },
|
||||
{ name = "s3transfer" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f2/e7/976bf3dfe0aa5d7f31bec2f2cf57c79641620c910a39bc843a237aa9592d/boto3-1.43.46.tar.gz", hash = "sha256:66c0d943b049a46a492ec4ec2ebe73c930b1842c7137bee83aad6d93e95d4d96", size = 112654, upload-time = "2026-07-10T19:32:12.498Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/53/05/23e1aa8c9e4b0399a61e7fd65c4f9cc0625121f24760e37471f776404abb/boto3-1.43.56.tar.gz", hash = "sha256:57c90df9fb026f2e6ae22530861198130203733c5c9ec4e5cca3a4037f5a8db4", size = 112673, upload-time = "2026-07-24T19:31:48.606Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/1d/c52e66ff32ba7911664e6c4c2ac62e1c6d2d1e7550c7ac185d3f4b70a8a4/boto3-1.43.46-py3-none-any.whl", hash = "sha256:69453e2c1bcb9fd9806527ab99950cacfc2826cb0dce9a3a0414d19270c06c3c", size = 140031, upload-time = "2026-07-10T19:32:11.129Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/57/3a960c9f581c00f2a591901b46e035ff79ab3956d16607f12306b3b8d483/boto3-1.43.56-py3-none-any.whl", hash = "sha256:feb699d4ab241ef5c1b80bb58277be2aaad365cd4b672d7817e0bc59ee45131b", size = 140026, upload-time = "2026-07-24T19:31:47.155Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -630,16 +630,16 @@ bedrock-runtime = [
|
||||
|
||||
[[package]]
|
||||
name = "botocore"
|
||||
version = "1.43.46"
|
||||
version = "1.43.56"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jmespath" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7d/f1/1917891851ac5ac09bb9f4862b8fc9252a009d7c24e8688bb67e4383d9e7/botocore-1.43.46.tar.gz", hash = "sha256:59f2e1ac3cdc66d191cae91c0804bc41847ce817dc8147cf43eaada8f76a5533", size = 15694635, upload-time = "2026-07-10T19:32:00.437Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b9/cc/7f84a5d3071fe878380e9f610ab36ca87b8cbbc4aa81ba2727f90e1f3ea3/botocore-1.43.56.tar.gz", hash = "sha256:6c01f85f0ff9863076f4c761e74ee3aa96c5ccc1ad09fc1efd62ef8f2d22bf57", size = 15733117, upload-time = "2026-07-24T19:31:38.125Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/f2/4bd8f2f419088feb3ce55f0ca91040ff902f402edfd197450b20a2e1d533/botocore-1.43.46-py3-none-any.whl", hash = "sha256:cb673891e623ae6e6a1bf24d94ef169504f3eb02584adb5d5bee2f6aae819b60", size = 15380350, upload-time = "2026-07-10T19:31:57.616Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/cd/86fe9e659e9699f62f8dd5ecd8c6725474334b23cab8aa71d82b5f56f1a4/botocore-1.43.56-py3-none-any.whl", hash = "sha256:aafc741f1b10f6fd63253eaf6ea029680c1ff436d87e1b8969d62aefa0c76976", size = 15418773, upload-time = "2026-07-24T19:31:34.758Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1281,7 +1281,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-agent"
|
||||
version = "1.16.0"
|
||||
version = "1.16.1"
|
||||
source = { editable = "../dify-agent" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -1331,7 +1331,7 @@ docs = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-api"
|
||||
version = "1.16.0"
|
||||
version = "1.16.1"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aliyun-log-python-sdk" },
|
||||
@@ -1621,7 +1621,7 @@ requires-dist = [
|
||||
{ name = "aliyun-log-python-sdk", specifier = "==0.9.44" },
|
||||
{ name = "azure-identity", specifier = ">=1.25.3,<2.0.0" },
|
||||
{ name = "bleach", specifier = ">=6.4.0,<7.0.0" },
|
||||
{ name = "boto3", specifier = ">=1.43.46,<2.0.0" },
|
||||
{ name = "boto3", specifier = ">=1.43.56,<2.0.0" },
|
||||
{ name = "celery", specifier = ">=5.6.3,<6.0.0" },
|
||||
{ name = "croniter", specifier = ">=6.2.2,<7.0.0" },
|
||||
{ name = "dify-agent", editable = "../dify-agent" },
|
||||
@@ -1727,10 +1727,10 @@ dev = [
|
||||
]
|
||||
storage = [
|
||||
{ name = "azure-storage-blob", specifier = ">=12.30.0,<13.0.0" },
|
||||
{ name = "bce-python-sdk", specifier = "==0.9.72" },
|
||||
{ name = "bce-python-sdk", specifier = "==0.9.76" },
|
||||
{ name = "cos-python-sdk-v5", specifier = ">=1.9.44,<2.0.0" },
|
||||
{ name = "esdk-obs-python", specifier = ">=3.26.6,<4.0.0" },
|
||||
{ name = "google-cloud-storage", specifier = ">=3.12.1,<4.0.0" },
|
||||
{ name = "google-cloud-storage", specifier = ">=3.13.0,<4.0.0" },
|
||||
{ name = "opendal", specifier = "==0.46.0" },
|
||||
{ name = "oss2", specifier = ">=2.19.1,<3.0.0" },
|
||||
{ name = "supabase", specifier = ">=2.31.0,<3.0.0" },
|
||||
@@ -2710,14 +2710,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "gitpython"
|
||||
version = "3.1.52"
|
||||
version = "3.1.54"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "gitdb" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e5/fd/df0bafa4eb5ea2f51e1adee9f7a94c8e62c5d180e65117045dfca3439c8a/gitpython-3.1.52.tar.gz", hash = "sha256:de0a8ad86274c6e75ae8b37dd055ba68f19818c813108642263227b20775b48e", size = 223726, upload-time = "2026-07-16T03:15:59.599Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5e/d5/3da0b92033887033f4c27f2dd109a303c4ca62813c7b3bb2511edb4777de/gitpython-3.1.54.tar.gz", hash = "sha256:53f2085e24a2cda300eed7c3fc5f1559ae289634b725e98acaf4791940247aa0", size = 225076, upload-time = "2026-07-22T04:08:51.403Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/90/04dff7c1e176bb1c3011ef1647393d368790da710d8dde1cdcfad301f45a/gitpython-3.1.52-py3-none-any.whl", hash = "sha256:79a36ee1f83523214a3f72d56cf1c4e490d577dc61af77e43dfe5862bd9da01a", size = 215366, upload-time = "2026-07-16T03:15:58.239Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/b9/876f442a28df5c068ca69b0122d5c35e65fd2d2fa9992ea5cb5944ea00a6/gitpython-3.1.54-py3-none-any.whl", hash = "sha256:b90d7b3d9bc0238681d24369130826f0dcdb0ceaa45db67cf1d4ffa4c302dedf", size = 216575, upload-time = "2026-07-22T04:08:50.05Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2891,7 +2891,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "google-cloud-storage"
|
||||
version = "3.12.1"
|
||||
version = "3.13.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "google-api-core" },
|
||||
@@ -2901,9 +2901,9 @@ dependencies = [
|
||||
{ name = "google-resumable-media" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/da/ac/60b4cb0a6c8c6bb7cedb8971ba5e34a94096acf76e2cc242bcf1e6fc5c49/google_cloud_storage-3.12.1.tar.gz", hash = "sha256:1d81491c7663bc26c5056d00b834356f2253b910ef467f9cf9928a87fca1e04b", size = 17339353, upload-time = "2026-07-08T17:03:59.142Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e3/25/355ed97c1723c787dfaa888808d55db18371f82c38ff862357b1e902cd19/google_cloud_storage-3.13.0.tar.gz", hash = "sha256:d11d8706ea1520fba0f21043bcb7897caf7015d76ce1ad9a4f60237e4d7a9f6c", size = 17340960, upload-time = "2026-07-13T19:10:07.524Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/80/6e/ca176e95bafac0fe7befeee7e0420e686de147571cd2908e308c5fe71bda/google_cloud_storage-3.12.1-py3-none-any.whl", hash = "sha256:9297ae0c2ce3f5400b1f2bb3a3e6d2cd256614366e03cd30600871df8e903afb", size = 340845, upload-time = "2026-07-08T17:03:31.418Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/e8/b3678a0931ee7d4b3fdaf0813e6206d66e0922b2c26d912f308728b5b95a/google_cloud_storage-3.13.0-py3-none-any.whl", hash = "sha256:648af3ef8a6acc674e1359d3c920c67eb89a7a5ab66b336bd3ac43fed6b5ab84", size = 341428, upload-time = "2026-07-13T19:09:52.39Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
+1
-1
@@ -71,7 +71,7 @@
|
||||
"channel": "alpha",
|
||||
"compat": {
|
||||
"minDify": "1.16.0",
|
||||
"maxDify": "1.16.0"
|
||||
"maxDify": "1.16.1"
|
||||
},
|
||||
"release": {
|
||||
"tagPrefix": "difyctl-v",
|
||||
|
||||
@@ -114,9 +114,15 @@ function die(msg) {
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
// Tests point this at a fixture manifest so their assertions stay fixed while
|
||||
// the real version and compat window move with every release. The name is
|
||||
// mirrored in test/fixtures/pkg-manifest.ts rather than imported from here,
|
||||
// because this file's shebang breaks the Windows test runner.
|
||||
const PKG_PATH_ENV = 'DIFYCTL_PKG_PATH'
|
||||
|
||||
function loadPkg() {
|
||||
const pkgUrl = new URL('../package.json', import.meta.url)
|
||||
const pkg = JSON.parse(readFileSync(pkgUrl, 'utf8'))
|
||||
const pkgPath = process.env[PKG_PATH_ENV] || new URL('../package.json', import.meta.url)
|
||||
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'))
|
||||
if (!pkg.difyctl?.release) die('cli/package.json missing difyctl.release')
|
||||
return {
|
||||
version: pkg.version,
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FIXTURE_COMPAT, pkgManifestEnv } from '../test/fixtures/pkg-manifest'
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('./release-naming.mjs', import.meta.url))
|
||||
|
||||
function run(args: string[]): { code: number; stdout: string; stderr: string } {
|
||||
function run(
|
||||
args: string[],
|
||||
env: Record<string, string> = {},
|
||||
): { code: number; stdout: string; stderr: string } {
|
||||
try {
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8' })
|
||||
const stdout = execFileSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...env },
|
||||
})
|
||||
return { code: 0, stdout, stderr: '' }
|
||||
} catch (e) {
|
||||
const err = e as { status?: number; stdout?: string; stderr?: string }
|
||||
@@ -14,45 +21,50 @@ function run(args: string[]): { code: number; stdout: string; stderr: string } {
|
||||
}
|
||||
}
|
||||
|
||||
describe('release-naming compat-check (compat 1.16.0..1.16.0)', () => {
|
||||
describe('release-naming compat-check', () => {
|
||||
const { minDify, maxDify } = FIXTURE_COMPAT // 2.0.0 .. 2.5.0
|
||||
const pkgEnv = pkgManifestEnv()
|
||||
const compatCheck = (difyVersion?: string) =>
|
||||
run(difyVersion === undefined ? ['compat-check'] : ['compat-check', difyVersion], pkgEnv).code
|
||||
|
||||
it('accepts a version inside the window', () => {
|
||||
expect(run(['compat-check', '1.16.0']).code).toBe(0)
|
||||
expect(compatCheck('2.3.0')).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts the inclusive lower bound', () => {
|
||||
expect(run(['compat-check', '1.16.0']).code).toBe(0)
|
||||
expect(compatCheck(minDify)).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts the inclusive upper bound', () => {
|
||||
expect(run(['compat-check', '1.16.0']).code).toBe(0)
|
||||
expect(compatCheck(maxDify)).toBe(0)
|
||||
})
|
||||
|
||||
it('accepts a v-prefixed tag', () => {
|
||||
expect(run(['compat-check', 'v1.16.0']).code).toBe(0)
|
||||
expect(compatCheck('v2.3.0')).toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a version below the lower bound', () => {
|
||||
expect(run(['compat-check', '1.15.9']).code).not.toBe(0)
|
||||
expect(compatCheck('1.9.9')).not.toBe(0)
|
||||
})
|
||||
|
||||
it('rejects a version above the upper bound', () => {
|
||||
expect(run(['compat-check', '1.16.1']).code).not.toBe(0)
|
||||
expect(compatCheck('2.5.1')).not.toBe(0)
|
||||
})
|
||||
|
||||
it('treats a prerelease of the bound as below it (1.16.0-rc1 < 1.16.0)', () => {
|
||||
expect(run(['compat-check', '1.16.0-rc1']).code).not.toBe(0)
|
||||
it('treats a prerelease of the lower bound as below it', () => {
|
||||
expect(compatCheck(`${minDify}-rc1`)).not.toBe(0)
|
||||
})
|
||||
|
||||
it('ignores build metadata on the bound (1.16.0+build == 1.16.0)', () => {
|
||||
expect(run(['compat-check', '1.16.0+build123']).code).toBe(0)
|
||||
it('ignores build metadata on the bound', () => {
|
||||
expect(compatCheck(`${maxDify}+build123`)).toBe(0)
|
||||
})
|
||||
|
||||
it('ignores build metadata when out of range (1.16.1+build still rejected)', () => {
|
||||
expect(run(['compat-check', '1.16.1+build123']).code).not.toBe(0)
|
||||
it('ignores build metadata when out of range', () => {
|
||||
expect(compatCheck('2.5.1+build123')).not.toBe(0)
|
||||
})
|
||||
|
||||
it('requires a version argument', () => {
|
||||
expect(run(['compat-check']).code).not.toBe(0)
|
||||
expect(compatCheck()).not.toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -67,6 +79,14 @@ describe('release-naming github-env', () => {
|
||||
for (const key of ['version', 'channel', 'prerelease', 'minDify', 'maxDify', 'tagPrefix'])
|
||||
expect(stdout).toMatch(new RegExp(`^${key}=`, 'm'))
|
||||
})
|
||||
|
||||
// The only assertion against the live manifest: the window must exist and be
|
||||
// well-formed, whatever release it currently points at.
|
||||
it('emits a well-formed compat window from the real cli/package.json', () => {
|
||||
const { stdout } = run(['github-env'])
|
||||
expect(stdout).toMatch(/^minDify=\d+\.\d+\.\d+$/m)
|
||||
expect(stdout).toMatch(/^maxDify=\d+\.\d+\.\d+$/m)
|
||||
})
|
||||
})
|
||||
|
||||
describe('release-naming edge channel', () => {
|
||||
|
||||
@@ -4,14 +4,20 @@ import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { FIXTURE_COMPAT, pkgManifestEnv } from '../test/fixtures/pkg-manifest'
|
||||
|
||||
const SCRIPT = fileURLToPath(new URL('./release-r2-edge.mjs', import.meta.url))
|
||||
|
||||
const PKG_ENV = pkgManifestEnv()
|
||||
|
||||
function run(args: string[]): { code: number; stdout: string; stderr: string } {
|
||||
try {
|
||||
return {
|
||||
code: 0,
|
||||
stdout: execFileSync('node', [SCRIPT, ...args], { encoding: 'utf8' }),
|
||||
stdout: execFileSync('node', [SCRIPT, ...args], {
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, ...PKG_ENV },
|
||||
}),
|
||||
stderr: '',
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -108,7 +114,7 @@ describe('release-r2-edge manifest', () => {
|
||||
|
||||
it('carries the compat window from package.json', () => {
|
||||
const { json } = buildManifest()
|
||||
expect(json.compat).toEqual({ minDify: '1.16.0', maxDify: '1.16.0' })
|
||||
expect(json.compat).toEqual(FIXTURE_COMPAT)
|
||||
})
|
||||
|
||||
it('lists all 5 targets with asset name + sha256 from the checksums file', () => {
|
||||
|
||||
Vendored
+57
@@ -0,0 +1,57 @@
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
// Mirrors PKG_PATH_ENV in scripts/release-naming.mjs, which cannot be imported
|
||||
// here: its shebang breaks the Windows test runner. Divergence is self-
|
||||
// reporting, not silent — the script would fall back to the real
|
||||
// cli/package.json and every fixture-window assertion would fail.
|
||||
const PKG_PATH_ENV = 'DIFYCTL_PKG_PATH'
|
||||
|
||||
// release-naming.mjs and release-r2-edge.mjs read their data from
|
||||
// cli/package.json. Tests spawn them against this fixture instead, so
|
||||
// assertions can name exact versions without tracking the live release.
|
||||
|
||||
// Deliberately far from any real Dify version, and min != max so "inside the
|
||||
// window" is a case distinct from either bound.
|
||||
export const FIXTURE_COMPAT = { minDify: '2.0.0', maxDify: '2.5.0' }
|
||||
|
||||
export const FIXTURE_TARGET_IDS = [
|
||||
'linux-x64',
|
||||
'linux-arm64',
|
||||
'darwin-x64',
|
||||
'darwin-arm64',
|
||||
'windows-x64',
|
||||
] as const
|
||||
|
||||
const FIXTURE_RELEASE = {
|
||||
tagPrefix: 'difyctl-v',
|
||||
binName: 'difyctl',
|
||||
checksumsSuffix: '-checksums.txt',
|
||||
targets: FIXTURE_TARGET_IDS.map((id) => ({
|
||||
id,
|
||||
bunTarget: `bun-${id}`,
|
||||
exe: id.startsWith('windows'),
|
||||
})),
|
||||
}
|
||||
|
||||
export type PkgManifestOverrides = {
|
||||
version?: string
|
||||
channel?: string
|
||||
compat?: { minDify: string; maxDify: string }
|
||||
}
|
||||
|
||||
// Returns the env additions that point a spawned script at the fixture.
|
||||
export function pkgManifestEnv(overrides: PkgManifestOverrides = {}): Record<string, string> {
|
||||
const manifest = {
|
||||
version: overrides.version ?? '0.2.0-alpha',
|
||||
difyctl: {
|
||||
channel: overrides.channel ?? 'alpha',
|
||||
compat: overrides.compat ?? FIXTURE_COMPAT,
|
||||
release: FIXTURE_RELEASE,
|
||||
},
|
||||
}
|
||||
const path = join(mkdtempSync(join(tmpdir(), 'difyctl-pkg-')), 'package.json')
|
||||
writeFileSync(path, JSON.stringify(manifest))
|
||||
return { [PKG_PATH_ENV]: path }
|
||||
}
|
||||
@@ -19,9 +19,9 @@ require (
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
|
||||
golang.org/x/net v0.53.0 // indirect
|
||||
golang.org/x/sys v0.43.0 // indirect
|
||||
golang.org/x/text v0.36.0 // indirect
|
||||
golang.org/x/net v0.55.0 // indirect
|
||||
golang.org/x/sys v0.45.0 // indirect
|
||||
golang.org/x/text v0.37.0 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
|
||||
kernel.org/pub/linux/libs/security/libcap/psx v1.2.77 // indirect
|
||||
modernc.org/libc v1.65.7 // indirect
|
||||
|
||||
+10
-10
@@ -45,19 +45,19 @@ go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLh
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
|
||||
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
|
||||
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
|
||||
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
|
||||
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
|
||||
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
|
||||
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
|
||||
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
|
||||
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
|
||||
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
|
||||
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
|
||||
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
|
||||
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
|
||||
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
|
||||
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
|
||||
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
|
||||
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
|
||||
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
|
||||
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
|
||||
|
||||
@@ -56,6 +56,12 @@ DIFY_AGENT_STUB_GRPC_BIND_ADDRESS=
|
||||
# Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))'
|
||||
DIFY_AGENT_SERVER_SECRET_KEY=MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY
|
||||
|
||||
# Inbound Bearer token for /runs API authentication.
|
||||
# Must match AGENT_BACKEND_API_TOKEN on the Dify API side.
|
||||
# Replace this development default in production.
|
||||
# Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))'
|
||||
DIFY_AGENT_API_TOKEN=dify-agent-run-token-for-dev-only
|
||||
|
||||
# Shared plugin-daemon HTTP client timeouts and limits.
|
||||
# Plugin-daemon HTTP connect timeout in seconds.
|
||||
DIFY_AGENT_PLUGIN_DAEMON_CONNECT_TIMEOUT=10
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-agent"
|
||||
version = "1.16.0"
|
||||
version = "1.16.1"
|
||||
description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12,<4.0"
|
||||
|
||||
@@ -29,6 +29,7 @@ from dify_agent.agent_stub.server.router import create_agent_stub_router
|
||||
from dify_agent.layers.execution_context import DifyExecutionContextLayerConfig
|
||||
from dify_agent.runtime.compositor_factory import create_default_layer_providers
|
||||
from dify_agent.runtime.run_scheduler import RunScheduler
|
||||
from dify_agent.server.auth import create_bearer_token_dependency
|
||||
from dify_agent.server.observability import configure_server_observability
|
||||
from dify_agent.server.routes.runs import create_runs_router
|
||||
from dify_agent.server.routes.sandbox_files import create_sandbox_files_router
|
||||
@@ -123,7 +124,13 @@ def create_app(settings: ServerSettings | None = None) -> FastAPI:
|
||||
def get_scheduler() -> RunScheduler:
|
||||
return state["scheduler"] # pyright: ignore[reportReturnType]
|
||||
|
||||
app.include_router(create_runs_router(get_store, get_scheduler))
|
||||
app.include_router(
|
||||
create_runs_router(
|
||||
get_store,
|
||||
get_scheduler,
|
||||
auth_dependency=create_bearer_token_dependency(resolved_settings.api_token),
|
||||
)
|
||||
)
|
||||
app.include_router(create_sandbox_files_router(lambda: sandbox_file_service))
|
||||
app.include_router(
|
||||
create_agent_stub_router(
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import hmac
|
||||
|
||||
from fastapi import Depends, Header, HTTPException
|
||||
|
||||
|
||||
def create_bearer_token_dependency(expected_token: str | None):
|
||||
"""Return a FastAPI dependency that validates Bearer token authentication.
|
||||
|
||||
When ``expected_token`` is ``None``, the returned dependency permits all
|
||||
requests without checking the header, supporting graceful migration for
|
||||
existing deployments.
|
||||
"""
|
||||
|
||||
async def require_bearer_token(
|
||||
authorization: str | None = Header(default=None, alias="Authorization"),
|
||||
) -> None:
|
||||
if expected_token is None:
|
||||
return
|
||||
if authorization is None:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="missing authorization header",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
scheme, _, token = authorization.partition(" ")
|
||||
token = token.strip()
|
||||
if scheme.lower() != "bearer" or not token:
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="invalid authorization scheme",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if not hmac.compare_digest(token.encode(), expected_token.encode()):
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="invalid bearer token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
return Depends(require_bearer_token)
|
||||
|
||||
|
||||
__all__ = ["create_bearer_token_dependency"]
|
||||
@@ -14,6 +14,7 @@ from collections.abc import Callable
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Query
|
||||
from fastapi.params import Depends as DependsInstance
|
||||
from fastapi.responses import StreamingResponse
|
||||
|
||||
from dify_agent.protocol.schemas import (
|
||||
@@ -32,9 +33,11 @@ from dify_agent.storage.redis_run_store import RedisRunStore, RunNotFoundError
|
||||
def create_runs_router(
|
||||
get_store: Callable[[], RedisRunStore],
|
||||
get_scheduler: Callable[[], RunScheduler],
|
||||
*,
|
||||
auth_dependency: DependsInstance | None = None,
|
||||
) -> APIRouter:
|
||||
"""Create routes bound to the application's store dependency provider."""
|
||||
router = APIRouter(prefix="/runs", tags=["runs"])
|
||||
dependencies: list[DependsInstance] = [auth_dependency] if auth_dependency is not None else []
|
||||
router = APIRouter(prefix="/runs", tags=["runs"], dependencies=dependencies)
|
||||
|
||||
async def store_dep() -> RedisRunStore:
|
||||
return get_store()
|
||||
|
||||
@@ -52,6 +52,7 @@ class ServerSettings(BaseSettings):
|
||||
agent_stub_api_base_url: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_API_BASE_URL")
|
||||
agent_stub_grpc_bind_address: str | None = Field(default=None, validation_alias="DIFY_AGENT_STUB_GRPC_BIND_ADDRESS")
|
||||
server_secret_key: str | None = None
|
||||
api_token: str | None = None
|
||||
shell_redact_patterns: str = ""
|
||||
outbound_http_connect_timeout: float = Field(default=10.0, ge=0)
|
||||
outbound_http_read_timeout: float = Field(default=600.0, ge=0)
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from dify_agent.server.auth import create_bearer_token_dependency
|
||||
|
||||
|
||||
def _build_app(expected_token: str | None) -> FastAPI:
|
||||
app = FastAPI()
|
||||
dep = create_bearer_token_dependency(expected_token)
|
||||
|
||||
@app.get("/protected", dependencies=[dep])
|
||||
async def protected() -> dict[str, str]:
|
||||
return {"status": "ok"}
|
||||
|
||||
return app
|
||||
|
||||
|
||||
class TestBearerTokenAuthEnabled:
|
||||
"""Auth is enforced when a non-None token is configured."""
|
||||
|
||||
def test_missing_header_returns_401(self) -> None:
|
||||
client = TestClient(_build_app("secret-token"))
|
||||
response = client.get("/protected")
|
||||
assert response.status_code == 401
|
||||
assert "missing" in response.json()["detail"]
|
||||
|
||||
def test_invalid_scheme_returns_401(self) -> None:
|
||||
client = TestClient(_build_app("secret-token"))
|
||||
response = client.get("/protected", headers={"Authorization": "Basic abc"})
|
||||
assert response.status_code == 401
|
||||
assert "scheme" in response.json()["detail"]
|
||||
|
||||
def test_wrong_token_returns_401(self) -> None:
|
||||
client = TestClient(_build_app("secret-token"))
|
||||
response = client.get("/protected", headers={"Authorization": "Bearer wrong"})
|
||||
assert response.status_code == 401
|
||||
assert "invalid bearer token" in response.json()["detail"]
|
||||
|
||||
def test_correct_token_passes(self) -> None:
|
||||
client = TestClient(_build_app("secret-token"))
|
||||
response = client.get("/protected", headers={"Authorization": "Bearer secret-token"})
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"status": "ok"}
|
||||
|
||||
|
||||
class TestBearerTokenAuthDisabled:
|
||||
"""Auth is a no-op when expected_token is None (backward compatibility)."""
|
||||
|
||||
def test_no_header_passes_when_token_unconfigured(self) -> None:
|
||||
client = TestClient(_build_app(None))
|
||||
response = client.get("/protected")
|
||||
assert response.status_code == 200
|
||||
|
||||
def test_any_header_passes_when_token_unconfigured(self) -> None:
|
||||
client = TestClient(_build_app(None))
|
||||
response = client.get("/protected", headers={"Authorization": "Bearer anything"})
|
||||
assert response.status_code == 200
|
||||
Generated
+4
-4
@@ -581,7 +581,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-agent"
|
||||
version = "1.16.0"
|
||||
version = "1.16.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
@@ -2704,15 +2704,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pymdown-extensions"
|
||||
version = "10.21.2"
|
||||
version = "11.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "markdown" },
|
||||
{ name = "pyyaml" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/df/08/f1c908c581fd11913da4711ea7ba32c0eee40b0190000996bb863b0c9349/pymdown_extensions-10.21.2.tar.gz", hash = "sha256:c3f55a5b8a1d0edf6699e35dcbea71d978d34ff3fa79f3d807b8a5b3fa90fbdc", size = 853922, upload-time = "2026-03-29T15:01:55.233Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/47/67/f1e79672a5f91985577c7984c9709ca110e4fd37fe7fd167b60422e6ccc2/pymdown_extensions-11.0.tar.gz", hash = "sha256:8269cef0247f9e2d0a62fcea10860aba05c1cbab5470fd4b63230b96434dc589", size = 857049, upload-time = "2026-06-23T02:27:45.146Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/27/a2fc51a4a122dfd1015e921ae9d22fee3d20b0b8080d9a704578bf9deece/pymdown_extensions-10.21.2-py3-none-any.whl", hash = "sha256:5c0fd2a2bea14eb39af8ff284f1066d898ab2187d81b889b75d46d4348c01638", size = 268901, upload-time = "2026-03-29T15:01:53.244Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/b6/1ae53367e28b9cffa3be7574e13fbe4589694272fd47710fbdbafd3d63c6/pymdown_extensions-11.0-py3-none-any.whl", hash = "sha256:fbc4acb641814fa9d17521bbd21a5240ef739a662f11c06330c4b78c93e954d6", size = 269415, upload-time = "2026-06-23T02:27:43.826Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -253,6 +253,10 @@ MARKETPLACE_URL=
|
||||
|
||||
# Dify Agent backend
|
||||
AGENT_BACKEND_BASE_URL=http://agent_backend:5050
|
||||
# Bearer token for the Agent backend /runs API.
|
||||
# Replace this development default in production.
|
||||
# Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))'
|
||||
DIFY_AGENT_API_TOKEN=dify-agent-run-token-for-dev-only
|
||||
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS=30
|
||||
AGENT_BACKEND_STREAM_MAX_RECONNECTS=3
|
||||
AGENT_BACKEND_RUN_TIMEOUT_SECONDS=1200
|
||||
|
||||
@@ -220,7 +220,7 @@ services:
|
||||
# API service
|
||||
api:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
environment:
|
||||
MODE: api
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@@ -232,6 +232,7 @@ services:
|
||||
PLUGIN_DAEMON_TIMEOUT: ${PLUGIN_DAEMON_TIMEOUT:-600.0}
|
||||
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
|
||||
AGENT_BACKEND_BASE_URL: ${AGENT_BACKEND_BASE_URL:-http://agent_backend:5050}
|
||||
AGENT_BACKEND_API_TOKEN: ${DIFY_AGENT_API_TOKEN:-dify-agent-run-token-for-dev-only}
|
||||
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS: ${AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS:-30}
|
||||
AGENT_BACKEND_STREAM_MAX_RECONNECTS: ${AGENT_BACKEND_STREAM_MAX_RECONNECTS:-3}
|
||||
AGENT_BACKEND_RUN_TIMEOUT_SECONDS: ${AGENT_BACKEND_RUN_TIMEOUT_SECONDS:-1200}
|
||||
@@ -270,7 +271,7 @@ services:
|
||||
# WebSocket service for workflow collaboration.
|
||||
api_websocket:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
profiles:
|
||||
- collaboration
|
||||
environment:
|
||||
@@ -296,7 +297,7 @@ services:
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
<<: *shared-worker-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
environment:
|
||||
MODE: worker
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@@ -305,6 +306,7 @@ services:
|
||||
PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
|
||||
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
|
||||
AGENT_BACKEND_BASE_URL: ${AGENT_BACKEND_BASE_URL:-http://agent_backend:5050}
|
||||
AGENT_BACKEND_API_TOKEN: ${DIFY_AGENT_API_TOKEN:-dify-agent-run-token-for-dev-only}
|
||||
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS: ${AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS:-30}
|
||||
AGENT_BACKEND_STREAM_MAX_RECONNECTS: ${AGENT_BACKEND_STREAM_MAX_RECONNECTS:-3}
|
||||
AGENT_BACKEND_RUN_TIMEOUT_SECONDS: ${AGENT_BACKEND_RUN_TIMEOUT_SECONDS:-1200}
|
||||
@@ -345,7 +347,7 @@ services:
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
<<: *shared-worker-beat-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
environment:
|
||||
MODE: beat
|
||||
depends_on:
|
||||
@@ -378,7 +380,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.16.0
|
||||
image: langgenius/dify-web:1.16.1
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/web.env
|
||||
@@ -540,7 +542,7 @@ services:
|
||||
# on port 3128, which only allows agent_backend /agent-stub/ and the Dify API
|
||||
# /files/* endpoints (see ssrf_proxy/squid-agent.conf.template).
|
||||
local_sandbox:
|
||||
image: langgenius/dify-agent-local-sandbox:1.16.0
|
||||
image: langgenius/dify-agent-local-sandbox:1.16.1
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/local-sandbox.env
|
||||
@@ -649,7 +651,7 @@ services:
|
||||
|
||||
# Dify Agent backend service.
|
||||
agent_backend:
|
||||
image: langgenius/dify-agent-backend:1.16.0
|
||||
image: langgenius/dify-agent-backend:1.16.1
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/dify-agent.env
|
||||
@@ -673,6 +675,7 @@ services:
|
||||
# Replace this development default in production.
|
||||
# Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))'
|
||||
DIFY_AGENT_SERVER_SECRET_KEY: ${DIFY_AGENT_SERVER_SECRET_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY}
|
||||
DIFY_AGENT_API_TOKEN: ${DIFY_AGENT_API_TOKEN:-dify-agent-run-token-for-dev-only}
|
||||
DIFY_AGENT_SHUTDOWN_GRACE_SECONDS: ${DIFY_AGENT_SHUTDOWN_GRACE_SECONDS:-30}
|
||||
DIFY_AGENT_RUN_RETENTION_SECONDS: ${DIFY_AGENT_RUN_RETENTION_SECONDS:-259200}
|
||||
depends_on:
|
||||
|
||||
@@ -226,7 +226,7 @@ services:
|
||||
# API service
|
||||
api:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
environment:
|
||||
MODE: api
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@@ -238,6 +238,7 @@ services:
|
||||
PLUGIN_DAEMON_TIMEOUT: ${PLUGIN_DAEMON_TIMEOUT:-600.0}
|
||||
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
|
||||
AGENT_BACKEND_BASE_URL: ${AGENT_BACKEND_BASE_URL:-http://agent_backend:5050}
|
||||
AGENT_BACKEND_API_TOKEN: ${DIFY_AGENT_API_TOKEN:-dify-agent-run-token-for-dev-only}
|
||||
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS: ${AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS:-30}
|
||||
AGENT_BACKEND_STREAM_MAX_RECONNECTS: ${AGENT_BACKEND_STREAM_MAX_RECONNECTS:-3}
|
||||
AGENT_BACKEND_RUN_TIMEOUT_SECONDS: ${AGENT_BACKEND_RUN_TIMEOUT_SECONDS:-1200}
|
||||
@@ -276,7 +277,7 @@ services:
|
||||
# WebSocket service for workflow collaboration.
|
||||
api_websocket:
|
||||
<<: *shared-api-worker-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
profiles:
|
||||
- collaboration
|
||||
environment:
|
||||
@@ -302,7 +303,7 @@ services:
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
<<: *shared-worker-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
environment:
|
||||
MODE: worker
|
||||
SENTRY_DSN: ${API_SENTRY_DSN:-}
|
||||
@@ -311,6 +312,7 @@ services:
|
||||
PLUGIN_MAX_PACKAGE_SIZE: ${PLUGIN_MAX_PACKAGE_SIZE:-52428800}
|
||||
INNER_API_KEY_FOR_PLUGIN: ${PLUGIN_DIFY_INNER_API_KEY:-QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1}
|
||||
AGENT_BACKEND_BASE_URL: ${AGENT_BACKEND_BASE_URL:-http://agent_backend:5050}
|
||||
AGENT_BACKEND_API_TOKEN: ${DIFY_AGENT_API_TOKEN:-dify-agent-run-token-for-dev-only}
|
||||
AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS: ${AGENT_BACKEND_STREAM_READ_TIMEOUT_SECONDS:-30}
|
||||
AGENT_BACKEND_STREAM_MAX_RECONNECTS: ${AGENT_BACKEND_STREAM_MAX_RECONNECTS:-3}
|
||||
AGENT_BACKEND_RUN_TIMEOUT_SECONDS: ${AGENT_BACKEND_RUN_TIMEOUT_SECONDS:-1200}
|
||||
@@ -351,7 +353,7 @@ services:
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
<<: *shared-worker-beat-config
|
||||
image: langgenius/dify-api:1.16.0
|
||||
image: langgenius/dify-api:1.16.1
|
||||
environment:
|
||||
MODE: beat
|
||||
depends_on:
|
||||
@@ -384,7 +386,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.16.0
|
||||
image: langgenius/dify-web:1.16.1
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/web.env
|
||||
@@ -546,7 +548,7 @@ services:
|
||||
# on port 3128, which only allows agent_backend /agent-stub/ and the Dify API
|
||||
# /files/* endpoints (see ssrf_proxy/squid-agent.conf.template).
|
||||
local_sandbox:
|
||||
image: langgenius/dify-agent-local-sandbox:1.16.0
|
||||
image: langgenius/dify-agent-local-sandbox:1.16.1
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/local-sandbox.env
|
||||
@@ -655,7 +657,7 @@ services:
|
||||
|
||||
# Dify Agent backend service.
|
||||
agent_backend:
|
||||
image: langgenius/dify-agent-backend:1.16.0
|
||||
image: langgenius/dify-agent-backend:1.16.1
|
||||
restart: always
|
||||
env_file:
|
||||
- path: ./envs/core-services/dify-agent.env
|
||||
@@ -679,6 +681,7 @@ services:
|
||||
# Replace this development default in production.
|
||||
# Generate one with: python -c 'import secrets; print(secrets.token_urlsafe(32))'
|
||||
DIFY_AGENT_SERVER_SECRET_KEY: ${DIFY_AGENT_SERVER_SECRET_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY}
|
||||
DIFY_AGENT_API_TOKEN: ${DIFY_AGENT_API_TOKEN:-dify-agent-run-token-for-dev-only}
|
||||
DIFY_AGENT_SHUTDOWN_GRACE_SECONDS: ${DIFY_AGENT_SHUTDOWN_GRACE_SECONDS:-30}
|
||||
DIFY_AGENT_RUN_RETENTION_SECONDS: ${DIFY_AGENT_RUN_RETENTION_SECONDS:-259200}
|
||||
depends_on:
|
||||
|
||||
@@ -73,6 +73,7 @@ SSRF_PROXY_HTTPS_URL=http://ssrf_proxy:3128
|
||||
PGDATA=/var/lib/postgresql/data/pgdata
|
||||
PLUGIN_MAX_PACKAGE_SIZE=52428800
|
||||
PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_ENABLED=true
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_TTL=86400
|
||||
# Comma-separated marketplace plugin IDs whose latest versions are installed for newly registered users.
|
||||
# Example: langgenius/openai,langgenius/gemini
|
||||
|
||||
@@ -31,6 +31,17 @@ const createApiResponse = ({
|
||||
status: () => status,
|
||||
statusText: () => statusText,
|
||||
text: async () => body,
|
||||
timing: () => ({
|
||||
connectEnd: -1,
|
||||
connectStart: -1,
|
||||
domainLookupEnd: -1,
|
||||
domainLookupStart: -1,
|
||||
requestStart: -1,
|
||||
responseEnd: -1,
|
||||
responseStart: -1,
|
||||
secureConnectionStart: -1,
|
||||
startTime: -1,
|
||||
}),
|
||||
url: () => url,
|
||||
[Symbol.asyncDispose]: async () => {},
|
||||
}
|
||||
|
||||
@@ -4,26 +4,11 @@
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"cli/test/e2e/helpers/retry.ts": {
|
||||
"no-throw-literal": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"cli/test/e2e/suites/output/json-yaml-output.e2e.ts": {
|
||||
"prefer-const": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"e2e/support/web-server.ts": {
|
||||
"no-throw-literal": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"packages/dev-proxy/src/cli.spec.ts": {
|
||||
"no-throw-literal": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"packages/migrate-no-unchecked-indexed-access/src/no-unchecked-indexed-access/migrate.ts": {
|
||||
"no-console": {
|
||||
"count": 11
|
||||
@@ -2141,11 +2126,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/voice-input/recorder.ts": {
|
||||
"no-throw-literal": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/billing/plan/assets/index.tsx": {
|
||||
"no-barrel-files/no-barrel-files": {
|
||||
"count": 4
|
||||
@@ -3225,9 +3205,6 @@
|
||||
}
|
||||
},
|
||||
"web/app/components/plugins/marketplace/hooks.ts": {
|
||||
"@tanstack/query/exhaustive-deps": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
@@ -6349,9 +6326,6 @@
|
||||
}
|
||||
},
|
||||
"web/service/use-pipeline.ts": {
|
||||
"@tanstack/query/exhaustive-deps": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
}
|
||||
@@ -6370,9 +6344,6 @@
|
||||
}
|
||||
},
|
||||
"web/service/use-workflow.ts": {
|
||||
"@tanstack/query/exhaustive-deps": {
|
||||
"count": 1
|
||||
},
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
|
||||
+1
-1
@@ -56,5 +56,5 @@
|
||||
"engines": {
|
||||
"node": "^22.22.1"
|
||||
},
|
||||
"packageManager": "pnpm@11.15.0"
|
||||
"packageManager": "pnpm@11.17.0"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -80,6 +80,10 @@ export type CheckDependenciesResult = {
|
||||
leaked_dependencies?: Array<PluginDependency>
|
||||
}
|
||||
|
||||
export type RecentAppListResponse = {
|
||||
data: Array<RecentAppResponse>
|
||||
}
|
||||
|
||||
export type WorkflowOnlineUsersPayload = {
|
||||
app_ids?: Array<string>
|
||||
}
|
||||
@@ -1408,6 +1412,20 @@ export type PluginDependency = {
|
||||
value: Github | Marketplace | Package
|
||||
}
|
||||
|
||||
export type RecentAppResponse = {
|
||||
author_name?: string | null
|
||||
icon?: string | null
|
||||
icon_background?: string | null
|
||||
icon_type?: IconType | null
|
||||
readonly icon_url: string | null
|
||||
id: string
|
||||
maintainer?: string | null
|
||||
mode: 'advanced-chat' | 'agent-chat' | 'chat' | 'completion' | 'workflow'
|
||||
name: string
|
||||
permission_keys?: Array<string>
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export type WorkflowOnlineUsersByApp = {
|
||||
app_id: string
|
||||
users: Array<WorkflowOnlineUser>
|
||||
@@ -3114,6 +3132,10 @@ export type AppDetailWithSiteWritable = {
|
||||
workflow?: WorkflowPartial | null
|
||||
}
|
||||
|
||||
export type RecentAppListResponseWritable = {
|
||||
data: Array<RecentAppResponseWritable>
|
||||
}
|
||||
|
||||
export type GeneratedAppResponseWritable = JsonValue
|
||||
|
||||
export type WorkflowCommentBasicListWritable = {
|
||||
@@ -3196,6 +3218,19 @@ export type AppDetailSiteResponseWritable = {
|
||||
use_icon_as_answer_icon?: boolean | null
|
||||
}
|
||||
|
||||
export type RecentAppResponseWritable = {
|
||||
author_name?: string | null
|
||||
icon?: string | null
|
||||
icon_background?: string | null
|
||||
icon_type?: IconType | null
|
||||
id: string
|
||||
maintainer?: string | null
|
||||
mode: 'advanced-chat' | 'agent-chat' | 'chat' | 'completion' | 'workflow'
|
||||
name: string
|
||||
permission_keys?: Array<string>
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
export type WorkflowCommentBasicWritable = {
|
||||
content: string
|
||||
created_at?: number | null
|
||||
@@ -3356,6 +3391,21 @@ export type PostAppsImportsByImportIdConfirmResponses = {
|
||||
export type PostAppsImportsByImportIdConfirmResponse =
|
||||
PostAppsImportsByImportIdConfirmResponses[keyof PostAppsImportsByImportIdConfirmResponses]
|
||||
|
||||
export type GetAppsRecentData = {
|
||||
body?: never
|
||||
path?: never
|
||||
query?: {
|
||||
limit?: number
|
||||
}
|
||||
url: '/apps/recent'
|
||||
}
|
||||
|
||||
export type GetAppsRecentResponses = {
|
||||
200: RecentAppListResponse
|
||||
}
|
||||
|
||||
export type GetAppsRecentResponse = GetAppsRecentResponses[keyof GetAppsRecentResponses]
|
||||
|
||||
export type GetAppsStarredData = {
|
||||
body?: never
|
||||
path?: never
|
||||
|
||||
@@ -1076,6 +1076,30 @@ export const zAppImportResponse = z.object({
|
||||
warnings: z.array(zDslImportWarning).optional(),
|
||||
})
|
||||
|
||||
/**
|
||||
* RecentAppResponse
|
||||
*/
|
||||
export const zRecentAppResponse = z.object({
|
||||
author_name: z.string().nullish(),
|
||||
icon: z.string().nullish(),
|
||||
icon_background: z.string().nullish(),
|
||||
icon_type: zIconType.nullish(),
|
||||
icon_url: z.string().nullable(),
|
||||
id: z.string(),
|
||||
maintainer: z.string().nullish(),
|
||||
mode: z.enum(['advanced-chat', 'agent-chat', 'chat', 'completion', 'workflow']),
|
||||
name: z.string(),
|
||||
permission_keys: z.array(z.string()).optional(),
|
||||
updated_at: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* RecentAppListResponse
|
||||
*/
|
||||
export const zRecentAppListResponse = z.object({
|
||||
data: z.array(zRecentAppResponse),
|
||||
})
|
||||
|
||||
export const zJsonValue = z
|
||||
.union([
|
||||
z.string(),
|
||||
@@ -4279,6 +4303,29 @@ export const zAppDetailWithSiteWritable = z.object({
|
||||
workflow: zWorkflowPartial.nullish(),
|
||||
})
|
||||
|
||||
/**
|
||||
* RecentAppResponse
|
||||
*/
|
||||
export const zRecentAppResponseWritable = z.object({
|
||||
author_name: z.string().nullish(),
|
||||
icon: z.string().nullish(),
|
||||
icon_background: z.string().nullish(),
|
||||
icon_type: zIconType.nullish(),
|
||||
id: z.string(),
|
||||
maintainer: z.string().nullish(),
|
||||
mode: z.enum(['advanced-chat', 'agent-chat', 'chat', 'completion', 'workflow']),
|
||||
name: z.string(),
|
||||
permission_keys: z.array(z.string()).optional(),
|
||||
updated_at: z.int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* RecentAppListResponse
|
||||
*/
|
||||
export const zRecentAppListResponseWritable = z.object({
|
||||
data: z.array(zRecentAppResponseWritable),
|
||||
})
|
||||
|
||||
/**
|
||||
* AccountWithRoleResponse
|
||||
*/
|
||||
@@ -4442,6 +4489,15 @@ export const zPostAppsImportsByImportIdConfirmPath = z.object({
|
||||
*/
|
||||
export const zPostAppsImportsByImportIdConfirmResponse = zImport
|
||||
|
||||
export const zGetAppsRecentQuery = z.object({
|
||||
limit: z.int().gte(1).lte(8).optional().default(8),
|
||||
})
|
||||
|
||||
/**
|
||||
* Success
|
||||
*/
|
||||
export const zGetAppsRecentResponse = zRecentAppListResponse
|
||||
|
||||
export const zGetAppsStarredQuery = z.object({
|
||||
creator_ids: z.array(z.string()).optional(),
|
||||
is_created_by_me: z.boolean().optional(),
|
||||
|
||||
@@ -60,7 +60,7 @@ const comboboxTriggerVariants = cva(
|
||||
[
|
||||
'group/combobox-trigger flex w-full min-w-0 items-center border-0 bg-components-input-bg-normal text-start text-components-input-text-filled outline-hidden transition-colors',
|
||||
'hover:bg-state-base-hover-alt focus-visible:bg-state-base-hover-alt data-popup-open:bg-state-base-hover-alt',
|
||||
'focus-visible:inset-ring-1 focus-visible:inset-ring-components-input-border-active',
|
||||
'focus-visible:ring-2 focus-visible:ring-state-accent-solid',
|
||||
'data-placeholder:text-components-input-text-placeholder',
|
||||
'data-readonly:cursor-default data-readonly:bg-transparent data-readonly:hover:bg-transparent',
|
||||
'data-disabled:cursor-not-allowed data-disabled:bg-components-input-bg-disabled data-disabled:text-components-input-text-filled-disabled data-disabled:hover:bg-components-input-bg-disabled',
|
||||
|
||||
Generated
+1989
-1585
File diff suppressed because it is too large
Load Diff
+58
-58
@@ -39,32 +39,32 @@ overrides:
|
||||
postcss-selector-parser@>=6.0.0 <6.1.3: 6.1.4
|
||||
postcss-selector-parser@>=7.0.0 <7.1.3: 7.1.4
|
||||
postcss@<8.5.10: ^8.5.10
|
||||
rollup@>=4.0.0 <4.59.0: 4.62.2
|
||||
rollup@>=4.0.0 <4.59.0: 4.62.3
|
||||
safer-buffer: npm:@nolyfill/safer-buffer@^1.0.44
|
||||
side-channel: npm:@nolyfill/side-channel@^1.0.44
|
||||
solid-js: 1.9.14
|
||||
string-width: ~8.2.2
|
||||
tar@<=7.5.15: ^7.5.16
|
||||
vite: npm:@voidzero-dev/vite-plus-core@0.2.5
|
||||
vite: npm:@voidzero-dev/vite-plus-core@0.2.6
|
||||
ws@>=8.0.0 <8.20.1: ^8.21.1
|
||||
yaml@>=2.0.0 <2.8.3: 2.9.0
|
||||
yauzl@<3.2.1: 3.2.1
|
||||
catalog:
|
||||
'@amplitude/analytics-browser': 2.45.2
|
||||
'@amplitude/plugin-session-replay-browser': 1.33.4
|
||||
'@amplitude/analytics-browser': 2.45.4
|
||||
'@amplitude/plugin-session-replay-browser': 1.33.6
|
||||
'@base-ui/react': 1.6.0
|
||||
'@chromatic-com/storybook': 5.2.1
|
||||
'@cucumber/cucumber': 13.1.1
|
||||
'@cucumber/cucumber': 13.2.0
|
||||
'@egoist/tailwindcss-icons': 1.9.2
|
||||
'@emoji-mart/data': 1.2.1
|
||||
'@eslint-community/eslint-plugin-eslint-comments': 4.7.2
|
||||
'@eslint-react/eslint-plugin': 5.17.1
|
||||
'@eslint-react/eslint-plugin': 5.18.0
|
||||
'@eslint/markdown': 8.0.3
|
||||
'@floating-ui/react': 0.27.20
|
||||
'@formatjs/intl-localematcher': 0.8.13
|
||||
'@heroicons/react': 2.2.0
|
||||
'@hey-api/openapi-ts': 0.98.2
|
||||
'@hono/node-server': 2.0.10
|
||||
'@hono/node-server': 2.0.12
|
||||
'@iconify-json/heroicons': 1.2.3
|
||||
'@iconify-json/ri': 1.2.10
|
||||
'@lexical/code': 0.47.0
|
||||
@@ -77,41 +77,41 @@ catalog:
|
||||
'@mdx-js/loader': 3.1.1
|
||||
'@mdx-js/react': 3.1.1
|
||||
'@mdx-js/rollup': 3.1.1
|
||||
'@mediabunny/mp3-encoder': 1.50.9
|
||||
'@mediabunny/mp3-encoder': 1.51.0
|
||||
'@monaco-editor/react': 4.7.0
|
||||
'@napi-rs/keyring': 1.3.0
|
||||
'@next/mdx': 16.2.11
|
||||
'@orpc/client': 1.14.8
|
||||
'@orpc/contract': 1.14.8
|
||||
'@orpc/openapi-client': 1.14.8
|
||||
'@orpc/tanstack-query': 1.14.8
|
||||
'@playwright/test': 1.61.1
|
||||
'@next/mdx': 16.2.12
|
||||
'@orpc/client': 1.14.10
|
||||
'@orpc/contract': 1.14.10
|
||||
'@orpc/openapi-client': 1.14.10
|
||||
'@orpc/tanstack-query': 1.14.10
|
||||
'@playwright/test': 1.62.0
|
||||
'@remixicon/react': 4.9.0
|
||||
'@rgrove/parse-xml': 4.2.2
|
||||
'@sentry/react': 10.66.0
|
||||
'@storybook/addon-a11y': 10.5.2
|
||||
'@storybook/addon-docs': 10.5.2
|
||||
'@storybook/addon-links': 10.5.2
|
||||
'@storybook/addon-onboarding': 10.5.2
|
||||
'@storybook/addon-themes': 10.5.2
|
||||
'@storybook/addon-vitest': 10.5.2
|
||||
'@storybook/nextjs-vite': 10.5.2
|
||||
'@storybook/react': 10.5.2
|
||||
'@storybook/react-vite': 10.5.2
|
||||
'@rgrove/parse-xml': 4.2.3
|
||||
'@sentry/react': 10.68.0
|
||||
'@storybook/addon-a11y': 10.5.4
|
||||
'@storybook/addon-docs': 10.5.4
|
||||
'@storybook/addon-links': 10.5.4
|
||||
'@storybook/addon-onboarding': 10.5.4
|
||||
'@storybook/addon-themes': 10.5.4
|
||||
'@storybook/addon-vitest': 10.5.4
|
||||
'@storybook/nextjs-vite': 10.5.4
|
||||
'@storybook/react': 10.5.4
|
||||
'@storybook/react-vite': 10.5.4
|
||||
'@streamdown/math': 1.0.2
|
||||
'@svgdotjs/svg.js': 3.2.6
|
||||
'@svgdotjs/svg.js': 3.2.7
|
||||
'@t3-oss/env-core': 0.13.11
|
||||
'@t3-oss/env-nextjs': 0.13.11
|
||||
'@tailwindcss/postcss': 4.3.3
|
||||
'@tailwindcss/typography': 0.5.20
|
||||
'@tailwindcss/vite': 4.3.3
|
||||
'@tanstack/eslint-plugin-query': 5.101.2
|
||||
'@tanstack/eslint-plugin-query': 5.101.4
|
||||
'@tanstack/form-core': 1.33.2
|
||||
'@tanstack/query-core': 5.101.2
|
||||
'@tanstack/query-core': 5.101.4
|
||||
'@tanstack/react-form': 1.33.2
|
||||
'@tanstack/react-hotkeys': 0.10.0
|
||||
'@tanstack/react-query': 5.101.2
|
||||
'@tanstack/react-virtual': 3.14.6
|
||||
'@tanstack/react-query': 5.101.4
|
||||
'@tanstack/react-virtual': 3.14.8
|
||||
'@testing-library/dom': 10.4.1
|
||||
'@testing-library/jest-dom': 6.9.1
|
||||
'@testing-library/react': 16.3.2
|
||||
@@ -127,9 +127,9 @@ catalog:
|
||||
'@types/react': 19.2.17
|
||||
'@types/react-dom': 19.2.3
|
||||
'@types/sortablejs': 1.15.9
|
||||
'@typescript-eslint/parser': 8.64.0
|
||||
'@typescript-eslint/parser': 8.65.0
|
||||
'@typescript/native': npm:typescript@7.0.2
|
||||
'@vitejs/plugin-react': 6.0.3
|
||||
'@vitejs/plugin-react': 6.0.4
|
||||
'@vitejs/plugin-rsc': 0.5.30
|
||||
'@vitest/browser': 4.1.10
|
||||
'@vitest/browser-playwright': 4.1.10
|
||||
@@ -143,7 +143,7 @@ catalog:
|
||||
cli-table3: 0.6.5
|
||||
clsx: 2.1.1
|
||||
code-inspector-plugin: 1.6.6
|
||||
concurrently: ^10.0.3
|
||||
concurrently: 10.0.4
|
||||
copy-to-clipboard: 4.0.2
|
||||
cron-parser: 5.6.2
|
||||
dayjs: 1.11.21
|
||||
@@ -156,32 +156,32 @@ catalog:
|
||||
embla-carousel-fade: 8.6.0
|
||||
embla-carousel-react: 8.6.0
|
||||
emoji-mart: 5.6.0
|
||||
es-toolkit: 1.49.0
|
||||
eslint: 10.7.0
|
||||
es-toolkit: 1.50.0
|
||||
eslint: 10.8.0
|
||||
eslint-markdown: 0.12.1
|
||||
eslint-plugin-antfu: 3.2.3
|
||||
eslint-plugin-command: 3.5.3
|
||||
eslint-plugin-erasable-syntax-only: 0.4.2
|
||||
eslint-plugin-hyoban: 0.14.1
|
||||
eslint-plugin-jsdoc: 63.1.0
|
||||
eslint-plugin-jsdoc: 63.3.1
|
||||
eslint-plugin-jsonc: 3.3.0
|
||||
eslint-plugin-markdown-preferences: 0.41.1
|
||||
eslint-plugin-n: 18.2.2
|
||||
eslint-plugin-no-barrel-files: 1.3.1
|
||||
eslint-plugin-perfectionist: 5.10.0
|
||||
eslint-plugin-pnpm: 1.6.1
|
||||
eslint-plugin-pnpm: 1.7.0
|
||||
eslint-plugin-regexp: 3.1.1
|
||||
eslint-plugin-storybook: 10.5.2
|
||||
eslint-plugin-toml: 1.4.0
|
||||
eslint-plugin-storybook: 10.5.4
|
||||
eslint-plugin-toml: 1.5.0
|
||||
eslint-plugin-unicorn: 71.1.0
|
||||
eslint-plugin-yml: 3.6.0
|
||||
eventsource-parser: 3.1.0
|
||||
fast-deep-equal: 3.1.3
|
||||
foxact: 0.3.8
|
||||
fuse.js: 7.5.0
|
||||
happy-dom: 20.11.0
|
||||
happy-dom: 20.11.1
|
||||
hast-util-to-jsx-runtime: 2.3.6
|
||||
hono: 4.12.31
|
||||
hono: 4.12.32
|
||||
html-entities: 2.6.0
|
||||
html-to-image: 1.11.13
|
||||
i18next: 26.3.6
|
||||
@@ -189,39 +189,39 @@ catalog:
|
||||
iconify-import-svg: 0.2.0
|
||||
immer: 11.1.15
|
||||
jotai: 2.20.2
|
||||
jotai-effect: 2.3.1
|
||||
jotai-effect: 2.4.1
|
||||
jotai-scope: 0.11.0
|
||||
jotai-tanstack-query: 0.11.0
|
||||
js-cookie: 3.0.8
|
||||
js-yaml: 5.2.1
|
||||
js-yaml: 5.2.2
|
||||
jsonschema: 1.5.0
|
||||
katex: 0.17.0
|
||||
knip: 6.27.0
|
||||
knip: 6.29.0
|
||||
ky: 2.0.2
|
||||
lexical: 0.47.0
|
||||
lockfile: 1.0.4
|
||||
loro-crdt: 1.13.7
|
||||
mediabunny: 1.50.9
|
||||
loro-crdt: 1.13.8
|
||||
mediabunny: 1.51.0
|
||||
mermaid: 11.16.0
|
||||
mime: 4.1.0
|
||||
mitt: 3.0.1
|
||||
motion: 12.42.2
|
||||
negotiator: 1.0.0
|
||||
next: 16.2.11
|
||||
next: 16.2.12
|
||||
next-themes: 0.4.6
|
||||
nuqs: 2.9.1
|
||||
nuqs: 2.9.2
|
||||
open: 11.0.0
|
||||
ora: 9.4.1
|
||||
picocolors: 1.1.1
|
||||
pinyin-pro: 3.28.1
|
||||
playwright: 1.61.1
|
||||
postcss: 8.5.19
|
||||
pinyin-pro: 3.28.2
|
||||
playwright: 1.62.0
|
||||
postcss: 8.5.23
|
||||
qrcode.react: 4.2.0
|
||||
qs: 6.15.3
|
||||
react: 19.2.8
|
||||
react-dom: 19.2.8
|
||||
react-easy-crop: 6.2.2
|
||||
react-i18next: 17.0.10
|
||||
react-easy-crop: 6.2.3
|
||||
react-i18next: 17.0.11
|
||||
react-papaparse: 4.4.0
|
||||
react-pdf-highlighter: 8.0.0-rc.0
|
||||
react-server-dom-webpack: 19.2.8
|
||||
@@ -237,7 +237,7 @@ catalog:
|
||||
socket.io-client: 4.8.3
|
||||
sortablejs: 1.15.7
|
||||
std-semver: 1.0.8
|
||||
storybook: 10.5.2
|
||||
storybook: 10.5.4
|
||||
streamdown: 2.5.0
|
||||
string-ts: 2.3.1
|
||||
tailwind-merge: 3.6.0
|
||||
@@ -246,14 +246,14 @@ catalog:
|
||||
tsx: 4.23.1
|
||||
typescript: npm:@typescript/typescript6@6.0.2
|
||||
uglify-js: 3.19.3
|
||||
undici: 7.28.0
|
||||
undici: 7.29.0
|
||||
unist-util-visit: 5.1.0
|
||||
use-context-selector: 2.0.0
|
||||
uuid: 14.0.1
|
||||
vinext: 1.0.0-beta.2
|
||||
vite: npm:@voidzero-dev/vite-plus-core@0.2.5
|
||||
vinext: 1.0.0-beta.4
|
||||
vite: npm:@voidzero-dev/vite-plus-core@0.2.6
|
||||
vite-plugin-inspect: 12.0.2
|
||||
vite-plus: 0.2.5
|
||||
vite-plus: 0.2.6
|
||||
vitest: 4.1.10
|
||||
vitest-browser-react: 2.2.0
|
||||
vitest-canvas-mock: 1.1.4
|
||||
|
||||
+100
-144
@@ -388,12 +388,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as ArizeConfig).api_key}
|
||||
onChange={handleConfigChange('api_key')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'API Key',
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'API Key',
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label="Space ID"
|
||||
@@ -401,12 +399,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as ArizeConfig).space_id}
|
||||
onChange={handleConfigChange('space_id')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'Space ID',
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'Space ID',
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label={t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' })!}
|
||||
@@ -414,12 +410,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as ArizeConfig).project}
|
||||
onChange={handleConfigChange('project')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label="Endpoint"
|
||||
@@ -438,12 +432,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as PhoenixConfig).api_key}
|
||||
onChange={handleConfigChange('api_key')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'API Key',
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'API Key',
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label={t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' })!}
|
||||
@@ -451,12 +443,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as PhoenixConfig).project}
|
||||
onChange={handleConfigChange('project')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label="Endpoint"
|
||||
@@ -475,12 +465,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as AliyunConfig).license_key}
|
||||
onChange={handleConfigChange('license_key')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'License Key',
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'License Key',
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label="Endpoint"
|
||||
@@ -505,9 +493,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as TencentConfig).token}
|
||||
onChange={handleConfigChange('token')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: 'Token' })!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'Token',
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label="Endpoint"
|
||||
@@ -535,12 +524,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as WeaveConfig).api_key}
|
||||
onChange={handleConfigChange('api_key')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'API Key',
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'API Key',
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label={t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' })!}
|
||||
@@ -548,21 +535,20 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as WeaveConfig).project}
|
||||
onChange={handleConfigChange('project')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label="Entity"
|
||||
labelClassName="text-sm!"
|
||||
value={(config as WeaveConfig).entity}
|
||||
onChange={handleConfigChange('entity')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], { ns: 'app', key: 'Entity' })!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'Entity',
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label="Endpoint"
|
||||
@@ -588,12 +574,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as LangSmithConfig).api_key}
|
||||
onChange={handleConfigChange('api_key')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'API Key',
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'API Key',
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label={t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' })!}
|
||||
@@ -601,12 +585,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as LangSmithConfig).project}
|
||||
onChange={handleConfigChange('project')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label="Endpoint"
|
||||
@@ -625,12 +607,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
value={(config as LangFuseConfig).secret_key}
|
||||
isRequired
|
||||
onChange={handleConfigChange('secret_key')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.secretKey`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.secretKey`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label={t(($) => $[`${I18N_PREFIX}.publicKey`], { ns: 'app' })!}
|
||||
@@ -638,12 +618,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as LangFuseConfig).public_key}
|
||||
onChange={handleConfigChange('public_key')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.publicKey`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.publicKey`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label="Host"
|
||||
@@ -662,24 +640,20 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
labelClassName="text-sm!"
|
||||
value={(config as OpikConfig).api_key}
|
||||
onChange={handleConfigChange('api_key')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'API Key',
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: 'API Key',
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label={t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' })!}
|
||||
labelClassName="text-sm!"
|
||||
value={(config as OpikConfig).project}
|
||||
onChange={handleConfigChange('project')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.project`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label="Workspace"
|
||||
@@ -713,36 +687,30 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
isRequired
|
||||
value={(config as MLflowConfig).experiment_id}
|
||||
onChange={handleConfigChange('experiment_id')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.experimentId`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.experimentId`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label={t(($) => $[`${I18N_PREFIX}.username`], { ns: 'app' })!}
|
||||
labelClassName="text-sm!"
|
||||
value={(config as MLflowConfig).username}
|
||||
onChange={handleConfigChange('username')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.username`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.username`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label={t(($) => $[`${I18N_PREFIX}.password`], { ns: 'app' })!}
|
||||
labelClassName="text-sm!"
|
||||
value={(config as MLflowConfig).password}
|
||||
onChange={handleConfigChange('password')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.password`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.password`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -753,12 +721,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
labelClassName="text-sm!"
|
||||
value={(config as DatabricksConfig).experiment_id}
|
||||
onChange={handleConfigChange('experiment_id')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.experimentId`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.experimentId`], { ns: 'app' }),
|
||||
})!}
|
||||
isRequired
|
||||
/>
|
||||
<Field
|
||||
@@ -766,12 +732,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
labelClassName="text-sm!"
|
||||
value={(config as DatabricksConfig).host}
|
||||
onChange={handleConfigChange('host')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.databricksHost`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.databricksHost`], { ns: 'app' }),
|
||||
})!}
|
||||
isRequired
|
||||
/>
|
||||
<Field
|
||||
@@ -779,36 +743,30 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
labelClassName="text-sm!"
|
||||
value={(config as DatabricksConfig).client_id}
|
||||
onChange={handleConfigChange('client_id')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.clientId`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.clientId`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label={t(($) => $[`${I18N_PREFIX}.clientSecret`], { ns: 'app' })!}
|
||||
labelClassName="text-sm!"
|
||||
value={(config as DatabricksConfig).client_secret}
|
||||
onChange={handleConfigChange('client_secret')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.clientSecret`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.clientSecret`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
<Field
|
||||
label={t(($) => $[`${I18N_PREFIX}.personalAccessToken`], { ns: 'app' })!}
|
||||
labelClassName="text-sm!"
|
||||
value={(config as DatabricksConfig).personal_access_token}
|
||||
onChange={handleConfigChange('personal_access_token')}
|
||||
placeholder={
|
||||
t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.personalAccessToken`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $[`${I18N_PREFIX}.placeholder`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`${I18N_PREFIX}.personalAccessToken`], { ns: 'app' }),
|
||||
})!}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
@@ -884,12 +842,10 @@ const ProviderConfigModal: FC<Props> = ({
|
||||
<AlertDialogContent>
|
||||
<div className="flex flex-col gap-2 px-6 pt-6 pb-4">
|
||||
<AlertDialogTitle className="w-full truncate title-2xl-semi-bold text-text-primary">
|
||||
{
|
||||
t(($) => $[`${I18N_PREFIX}.removeConfirmTitle`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`tracing.${type}.title`], { ns: 'app' }),
|
||||
})!
|
||||
}
|
||||
{t(($) => $[`${I18N_PREFIX}.removeConfirmTitle`], {
|
||||
ns: 'app',
|
||||
key: t(($) => $[`tracing.${type}.title`], { ns: 'app' }),
|
||||
})!}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription className="w-full system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
|
||||
{t(($) => $[`${I18N_PREFIX}.removeConfirmContent`], { ns: 'app' })}
|
||||
|
||||
@@ -118,7 +118,7 @@ export default function AddMemberOrGroupDialog() {
|
||||
aria-label={t(($) => $['operation.add'], { ns: 'common' })}
|
||||
icon={false}
|
||||
size="small"
|
||||
className="h-6 w-auto min-w-[52px] shrink-0 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover focus-visible:ring-2 focus-visible:ring-state-accent-solid data-popup-open:bg-state-accent-hover"
|
||||
className="h-6 w-auto min-w-[52px] shrink-0 rounded-md border-0 bg-transparent px-2 py-0 text-xs font-medium text-components-button-secondary-accent-text hover:bg-state-accent-hover focus-visible:bg-state-accent-hover data-popup-open:bg-state-accent-hover"
|
||||
>
|
||||
<span className="inline-flex min-w-0 items-center justify-center gap-x-0.5 whitespace-nowrap">
|
||||
<span className="i-ri-add-circle-fill size-4 shrink-0" aria-hidden="true" />
|
||||
|
||||
@@ -145,7 +145,7 @@ export function DocumentPicker({ datasetId, value, parentMode, onChange }: Props
|
||||
aria-label={value?.name || t(($) => $['operation.search'], { ns: 'common' })}
|
||||
icon={false}
|
||||
className={cn(
|
||||
'ml-1 flex size-auto rounded-lg border-0 bg-transparent px-2 py-1 hover:bg-state-base-hover focus-visible:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-active data-popup-open:bg-state-base-hover',
|
||||
'ml-1 flex size-auto rounded-lg border-0 bg-transparent px-2 py-1 hover:bg-state-base-hover focus-visible:bg-state-base-hover data-popup-open:bg-state-base-hover',
|
||||
)}
|
||||
>
|
||||
<ComboboxValue>
|
||||
|
||||
@@ -143,9 +143,9 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
|
||||
<div className="flex gap-3">
|
||||
<DelimiterInput
|
||||
value={parentChildConfig.parent.delimiter}
|
||||
tooltip={
|
||||
t(($) => $['stepTwo.parentChildDelimiterTip'], { ns: 'datasetCreation' })!
|
||||
}
|
||||
tooltip={t(($) => $['stepTwo.parentChildDelimiterTip'], {
|
||||
ns: 'datasetCreation',
|
||||
})!}
|
||||
onChange={(e) => onParentDelimiterChange(e.target.value)}
|
||||
/>
|
||||
<MaxLengthInput
|
||||
@@ -178,9 +178,9 @@ export const ParentChildOptions: FC<ParentChildOptionsProps> = ({
|
||||
<div className="mt-1 flex gap-3">
|
||||
<DelimiterInput
|
||||
value={parentChildConfig.child.delimiter}
|
||||
tooltip={
|
||||
t(($) => $['stepTwo.parentChildChunkDelimiterTip'], { ns: 'datasetCreation' })!
|
||||
}
|
||||
tooltip={t(($) => $['stepTwo.parentChildChunkDelimiterTip'], {
|
||||
ns: 'datasetCreation',
|
||||
})!}
|
||||
onChange={(e) => onChildDelimiterChange(e.target.value)}
|
||||
/>
|
||||
<MaxLengthInput
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type {
|
||||
StepByStepTourStatePatchPayload,
|
||||
StepByStepTourStateResponse,
|
||||
@@ -9,7 +10,6 @@ import type { CreateAppModalProps } from '@/app/components/explore/create-app-mo
|
||||
import type { StepByStepTourSessionState } from '@/app/components/step-by-step-tour/types'
|
||||
import type { Banner as BannerType } from '@/models/app'
|
||||
import type { App } from '@/models/explore'
|
||||
import type { App as WorkspaceApp } from '@/types/app'
|
||||
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { createStore, Provider as JotaiProvider, useSetAtom } from 'jotai'
|
||||
@@ -57,7 +57,7 @@ let mockExploreData: { categories: string[]; allList: App[] } | undefined = {
|
||||
}
|
||||
let mockLearnDifyApps: App[] = []
|
||||
let mockLearnDifyLoading = false
|
||||
let mockWorkspaceApps: WorkspaceApp[] = []
|
||||
let mockWorkspaceApps: RecentAppResponse[] = []
|
||||
let mockWorkspaceAppsLoading = false
|
||||
let mockBanners: BannerType[] = []
|
||||
let mockBannersLoading = false
|
||||
@@ -67,6 +67,10 @@ const mockHandleImportDSL = vi.fn()
|
||||
const mockHandleImportDSLConfirm = vi.fn()
|
||||
const mockTrackCreateApp = vi.fn()
|
||||
const mockTrackEvent = vi.hoisted(() => vi.fn())
|
||||
const mockAppQueries = vi.hoisted(() => ({
|
||||
listQueryOptions: vi.fn(),
|
||||
recentQueryOptions: vi.fn(),
|
||||
}))
|
||||
const mockStepByStepTour = vi.hoisted(() => {
|
||||
const stateQueryKey = ['console', 'onboarding', 'step-by-step-tour', 'state'] as const
|
||||
const createState = (
|
||||
@@ -242,13 +246,14 @@ vi.mock('@/service/client', () => ({
|
||||
queryOptions: (options: {
|
||||
input?: { query?: { limit?: number } }
|
||||
select?: (response: {
|
||||
data: WorkspaceApp[]
|
||||
data: RecentAppResponse[]
|
||||
has_more: boolean
|
||||
limit: number
|
||||
page: number
|
||||
total: number
|
||||
}) => unknown
|
||||
}) => {
|
||||
mockAppQueries.listQueryOptions(options)
|
||||
const limit = options.input?.query?.limit ?? mockWorkspaceApps.length
|
||||
if (mockWorkspaceAppsLoading) {
|
||||
return {
|
||||
@@ -272,6 +277,33 @@ vi.mock('@/service/client', () => ({
|
||||
}
|
||||
},
|
||||
},
|
||||
recent: {
|
||||
get: {
|
||||
queryOptions: (options: {
|
||||
input?: { query?: { limit?: number } }
|
||||
select?: (response: { data: RecentAppResponse[] }) => unknown
|
||||
}) => {
|
||||
mockAppQueries.recentQueryOptions(options)
|
||||
const limit = options.input?.query?.limit ?? mockWorkspaceApps.length
|
||||
if (mockWorkspaceAppsLoading) {
|
||||
return {
|
||||
queryKey: ['console', 'apps', 'recent', 'get', options],
|
||||
queryFn: () => new Promise(() => {}),
|
||||
select: options.select,
|
||||
}
|
||||
}
|
||||
const response = {
|
||||
data: mockWorkspaceApps.slice(0, limit),
|
||||
}
|
||||
return {
|
||||
queryKey: ['console', 'apps', 'recent', 'get', options],
|
||||
queryFn: () => Promise.resolve(response),
|
||||
initialData: response,
|
||||
select: options.select,
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
onboarding: {
|
||||
stepByStepTour: {
|
||||
@@ -455,33 +487,19 @@ const createApp = (overrides: Partial<App> = {}): App => ({
|
||||
is_agent: overrides.is_agent ?? false,
|
||||
})
|
||||
|
||||
const createWorkspaceApp = (overrides: Partial<WorkspaceApp> = {}): WorkspaceApp =>
|
||||
({
|
||||
id: overrides.id ?? 'workspace-app-1',
|
||||
name: overrides.name ?? 'Workspace App',
|
||||
description: overrides.description ?? 'Workspace app description',
|
||||
author_name: overrides.author_name ?? 'Evan',
|
||||
icon_type: overrides.icon_type ?? 'emoji',
|
||||
icon: overrides.icon ?? '😀',
|
||||
icon_background: overrides.icon_background ?? '#fff',
|
||||
icon_url: overrides.icon_url ?? null,
|
||||
use_icon_as_answer_icon: overrides.use_icon_as_answer_icon ?? false,
|
||||
mode: overrides.mode ?? AppModeEnum.CHAT,
|
||||
created_at: overrides.created_at ?? 1704067200,
|
||||
updated_at: overrides.updated_at ?? 1704153600,
|
||||
enable_site: overrides.enable_site ?? false,
|
||||
enable_api: overrides.enable_api ?? false,
|
||||
api_rpm: overrides.api_rpm ?? 60,
|
||||
api_rph: overrides.api_rph ?? 3600,
|
||||
is_demo: overrides.is_demo ?? false,
|
||||
model_config: overrides.model_config,
|
||||
app_model_config: overrides.app_model_config,
|
||||
site: overrides.site,
|
||||
api_base_url: overrides.api_base_url ?? '',
|
||||
tags: overrides.tags ?? [],
|
||||
access_mode: overrides.access_mode,
|
||||
permission_keys: overrides.permission_keys,
|
||||
}) as WorkspaceApp
|
||||
const createWorkspaceApp = (overrides: Partial<RecentAppResponse> = {}): RecentAppResponse => ({
|
||||
id: overrides.id ?? 'workspace-app-1',
|
||||
name: overrides.name ?? 'Workspace App',
|
||||
author_name: overrides.author_name ?? 'Evan',
|
||||
icon_type: overrides.icon_type ?? 'emoji',
|
||||
icon: overrides.icon ?? '😀',
|
||||
icon_background: overrides.icon_background ?? '#fff',
|
||||
icon_url: overrides.icon_url ?? null,
|
||||
mode: overrides.mode ?? 'chat',
|
||||
updated_at: overrides.updated_at ?? 1704153600,
|
||||
maintainer: overrides.maintainer ?? 'user-1',
|
||||
permission_keys: overrides.permission_keys,
|
||||
})
|
||||
|
||||
const createBanner = (overrides: Partial<BannerType> = {}): BannerType => ({
|
||||
id: overrides.id ?? 'banner-1',
|
||||
@@ -748,6 +766,27 @@ describe('AppList', () => {
|
||||
).toHaveAttribute('href', '/apps')
|
||||
})
|
||||
|
||||
it('should load continue work from the lightweight recent apps query', () => {
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
allList: [createApp()],
|
||||
}
|
||||
mockWorkspaceApps = [createWorkspaceApp()]
|
||||
|
||||
renderAppList()
|
||||
|
||||
expect(mockAppQueries.recentQueryOptions).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
input: {
|
||||
query: {
|
||||
limit: 8,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
expect(mockAppQueries.listQueryOptions).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should render preview-only continue work app as a dimmed card and warn on click', () => {
|
||||
mockExploreData = {
|
||||
categories: ['Writing'],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { App } from '@/models/explore'
|
||||
import type { App as WorkspaceApp } from '@/types/app'
|
||||
import type { TryAppSelection } from '@/types/try-app'
|
||||
import ContinueWork from '@/app/components/explore/continue-work'
|
||||
import { STEP_BY_STEP_TOUR_TARGETS } from '@/app/components/step-by-step-tour/target-registry'
|
||||
@@ -17,7 +17,7 @@ export function ExploreRecommendations({
|
||||
onTry,
|
||||
}: {
|
||||
canCreate: boolean
|
||||
continueWorkApps: WorkspaceApp[]
|
||||
continueWorkApps: RecentAppResponse[]
|
||||
forceShowLearnDify?: boolean
|
||||
onCreate: (app: App) => void
|
||||
onTry: (params: TryAppSelection) => void
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
|
||||
import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { CreateAppModalProps } from '@/app/components/explore/create-app-modal'
|
||||
import type { StepByStepTourTaskId } from '@/app/components/step-by-step-tour/types'
|
||||
import type { Banner as BannerType } from '@/models/app'
|
||||
import type { App } from '@/models/explore'
|
||||
import type { App as WorkspaceApp } from '@/types/app'
|
||||
import type { TryAppSelection } from '@/types/try-app'
|
||||
import type { TrackCreateAppParams } from '@/utils/create-app-tracking'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
@@ -15,10 +15,8 @@ import { useQueryState } from 'nuqs'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import DSLConfirmModal from '@/app/components/app/create-from-dsl-modal/dsl-confirm-modal'
|
||||
import AppCard from '@/app/components/explore/app-card'
|
||||
import { Banner } from '@/app/components/explore/banner/banner'
|
||||
import CreateAppModal from '@/app/components/explore/create-app-modal'
|
||||
import {
|
||||
getStepByStepTourPermissionVariant,
|
||||
trackStepByStepTourEvent,
|
||||
@@ -41,7 +39,6 @@ import { DSLImportMode } from '@/models/app'
|
||||
import dynamic from '@/next/dynamic'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { fetchAppDetail, fetchAppList, fetchBanners } from '@/service/explore'
|
||||
import { normalizeAppPagination } from '@/service/use-apps'
|
||||
import { trackCreateApp } from '@/utils/create-app-tracking'
|
||||
import { hasPermission } from '@/utils/permission'
|
||||
import { ExploreAppListHeader } from './explore-app-list-header'
|
||||
@@ -50,6 +47,11 @@ import { ExploreHomeSkeleton } from './loading-skeletons'
|
||||
import s from './style.module.css'
|
||||
|
||||
const TryApp = dynamic(() => import('../try-app'), { ssr: false })
|
||||
const CreateAppModal = dynamic(() => import('../create-app-modal'), { ssr: false })
|
||||
const DSLConfirmModal = dynamic(
|
||||
() => import('@/app/components/app/create-from-dsl-modal/dsl-confirm-modal'),
|
||||
{ ssr: false },
|
||||
)
|
||||
|
||||
type ExploreAppListData = {
|
||||
categories: string[]
|
||||
@@ -58,9 +60,7 @@ type ExploreAppListData = {
|
||||
|
||||
const homeContinueWorkAppsInput = {
|
||||
query: {
|
||||
page: 1,
|
||||
limit: 8,
|
||||
name: '',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -88,9 +88,9 @@ function getExploreAppListQueryOptions(locale?: string) {
|
||||
}
|
||||
|
||||
function getContinueWorkAppsQueryOptions() {
|
||||
return consoleQuery.apps.get.queryOptions({
|
||||
return consoleQuery.apps.recent.get.queryOptions({
|
||||
input: homeContinueWorkAppsInput,
|
||||
select: (response): WorkspaceApp[] => normalizeAppPagination(response).data,
|
||||
select: (response): RecentAppResponse[] => response.data,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import type { AnchorHTMLAttributes, ReactNode } from 'react'
|
||||
import type { App } from '@/types/app'
|
||||
import { fireEvent, screen } from '@testing-library/react'
|
||||
import { AccessMode } from '@/models/access-control'
|
||||
import { renderWithConsoleQuery } from '@/test/console/query-data'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { AppACLPermission } from '@/utils/permission'
|
||||
import ContinueWorkItem from '../item'
|
||||
|
||||
@@ -52,37 +50,23 @@ vi.mock('@/next/link', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
const createApp = (overrides: Partial<App> = {}): App => ({
|
||||
const createApp = (overrides: Partial<RecentAppResponse> = {}): RecentAppResponse => ({
|
||||
id: 'app-1',
|
||||
name: 'Continue App',
|
||||
description: 'Continue app description',
|
||||
author_name: 'Alice',
|
||||
icon_type: 'emoji',
|
||||
icon: '🤖',
|
||||
icon_background: '#FFEAD5',
|
||||
icon_url: null,
|
||||
use_icon_as_answer_icon: false,
|
||||
mode: AppModeEnum.CHAT,
|
||||
enable_site: false,
|
||||
enable_api: false,
|
||||
api_rpm: 60,
|
||||
api_rph: 3600,
|
||||
is_demo: false,
|
||||
model_config: {} as App['model_config'],
|
||||
app_model_config: {} as App['app_model_config'],
|
||||
created_at: 100,
|
||||
mode: 'chat',
|
||||
maintainer: 'maintainer-1',
|
||||
updated_at: 200,
|
||||
site: {} as App['site'],
|
||||
api_base_url: '',
|
||||
tags: [],
|
||||
access_mode: AccessMode.PUBLIC,
|
||||
permission_keys: [AppACLPermission.Edit],
|
||||
...overrides,
|
||||
})
|
||||
|
||||
const renderItem = (
|
||||
app: App,
|
||||
app: RecentAppResponse,
|
||||
systemFeatures: NonNullable<Parameters<typeof renderWithConsoleQuery>[1]>['systemFeatures'] = {
|
||||
rbac_enabled: true,
|
||||
},
|
||||
@@ -109,12 +93,6 @@ describe('ContinueWorkItem', () => {
|
||||
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(200000)
|
||||
})
|
||||
|
||||
it('should use created time when updated time is missing', () => {
|
||||
renderItem(createApp({ updated_at: 0, created_at: 123 }))
|
||||
|
||||
expect(mockFormatTimeFromNow).toHaveBeenCalledWith(123000)
|
||||
})
|
||||
|
||||
it('should link to access config when RBAC is enabled and only access config permission is available', () => {
|
||||
renderItem(createApp({ permission_keys: [AppACLPermission.AccessConfig] }))
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { App as WorkspaceApp } from '@/types/app'
|
||||
import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -8,7 +8,7 @@ import Link from '@/next/link'
|
||||
import ContinueWorkItem from './item'
|
||||
|
||||
type ContinueWorkProps = {
|
||||
apps: WorkspaceApp[]
|
||||
apps: RecentAppResponse[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import type { App } from '@/types/app'
|
||||
import type { RecentAppResponse } from '@dify/contracts/api/console/apps/types.gen'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||
@@ -18,7 +18,7 @@ import { getRedirectionPath } from '@/utils/app-redirection'
|
||||
import { hasOnlyAppPreviewPermission } from '@/utils/permission'
|
||||
|
||||
type ContinueWorkItemProps = {
|
||||
app: App
|
||||
app: RecentAppResponse
|
||||
}
|
||||
|
||||
const ContinueWorkItem = ({ app }: ContinueWorkItemProps) => {
|
||||
@@ -28,7 +28,7 @@ const ContinueWorkItem = ({ app }: ContinueWorkItemProps) => {
|
||||
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
|
||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||
const isRbacEnabled = systemFeatures.rbac_enabled
|
||||
const updatedAt = (app.updated_at || app.created_at) * 1000
|
||||
const updatedAt = app.updated_at * 1000
|
||||
const isPreviewOnly = hasOnlyAppPreviewPermission(app.permission_keys)
|
||||
const href = getRedirectionPath(app, {
|
||||
currentUserId,
|
||||
@@ -58,7 +58,7 @@ const ContinueWorkItem = ({ app }: ContinueWorkItemProps) => {
|
||||
<AppIcon
|
||||
size="large"
|
||||
iconType={app.icon_type}
|
||||
icon={app.icon}
|
||||
icon={app.icon ?? undefined}
|
||||
background={app.icon_background}
|
||||
imageUrl={app.icon_url}
|
||||
/>
|
||||
|
||||
+1
-1
@@ -144,7 +144,7 @@ function ModelSelector({
|
||||
<ComboboxTrigger
|
||||
aria-label={t(($) => $['detailPanel.configureModel'], { ns: 'plugin' })}
|
||||
icon={false}
|
||||
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-popup-open:bg-transparent"
|
||||
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent data-popup-open:bg-transparent"
|
||||
disabled={readonly}
|
||||
>
|
||||
<ModelSelectorTrigger
|
||||
|
||||
@@ -655,6 +655,21 @@ describe('MainNav', () => {
|
||||
expect(helpButton.parentElement).toHaveClass('shrink-0', 'rounded-full', 'p-1')
|
||||
})
|
||||
|
||||
it('orders the Step-by-step Tour before the account and help actions', async () => {
|
||||
localStorage.setItem(STEP_BY_STEP_TOUR_SHELL_MODE_STORAGE_KEY, 'collapsed')
|
||||
|
||||
renderMainNav()
|
||||
|
||||
const tourTrigger = await screen.findByRole('button', { name: 'Open step-by-step tour' })
|
||||
const accountButton = screen.getByRole('button', { name: 'common.account.account' })
|
||||
const helpButton = screen.getByRole('button', { name: 'common.mainNav.help.openMenu' })
|
||||
|
||||
expect(tourTrigger.compareDocumentPosition(accountButton)).toBe(
|
||||
Node.DOCUMENT_POSITION_FOLLOWING,
|
||||
)
|
||||
expect(accountButton.compareDocumentPosition(helpButton)).toBe(Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
})
|
||||
|
||||
it('keeps the global navigation account section expanded on home routes', () => {
|
||||
localStorage.setItem(DETAIL_SIDEBAR_STORAGE_KEY, 'collapse')
|
||||
mockPathname = '/'
|
||||
|
||||
@@ -133,6 +133,7 @@ export function MainNav({ className }: MainNavProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className="relative w-60 shrink-0">
|
||||
<StepByStepTourMount className="absolute -top-7 left-2.5 h-8 w-[183px] overflow-visible" />
|
||||
<div className="flex w-60 items-center justify-between bg-gradient-to-b from-background-body-transparent to-background-body to-50% py-3 pr-1 pl-3 backdrop-blur-[2px]">
|
||||
<div className="flex min-w-0 items-center gap-1 overflow-hidden">
|
||||
<AccountSection />
|
||||
@@ -141,7 +142,6 @@ export function MainNav({ className }: MainNavProps) {
|
||||
<HelpMenu />
|
||||
</div>
|
||||
</div>
|
||||
<StepByStepTourMount className="absolute -top-7 left-2.5 h-8 w-[183px] overflow-visible" />
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
@@ -130,7 +130,7 @@ export function AppPicker({
|
||||
<ComboboxTrigger
|
||||
aria-label={t(($) => $['appSelector.label'], { ns: 'app' })}
|
||||
icon={false}
|
||||
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 data-popup-open:bg-transparent"
|
||||
className="block h-auto w-full border-0 bg-transparent p-0 text-left hover:bg-transparent focus-visible:bg-transparent data-popup-open:bg-transparent"
|
||||
>
|
||||
{trigger}
|
||||
</ComboboxTrigger>
|
||||
|
||||
@@ -217,11 +217,9 @@ export default function ConfigCredential({ positionCenter, credential, onChange,
|
||||
onChange={(e) =>
|
||||
setTempCredential({ ...tempCredential, api_key_header: e.target.value })
|
||||
}
|
||||
placeholder={
|
||||
t(($) => $['createTool.authMethod.types.apiKeyPlaceholder'], {
|
||||
ns: 'tools',
|
||||
})!
|
||||
}
|
||||
placeholder={t(($) => $['createTool.authMethod.types.apiKeyPlaceholder'], {
|
||||
ns: 'tools',
|
||||
})!}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -233,11 +231,12 @@ export default function ConfigCredential({ positionCenter, credential, onChange,
|
||||
onChange={(e) =>
|
||||
setTempCredential({ ...tempCredential, api_key_value: e.target.value })
|
||||
}
|
||||
placeholder={
|
||||
t(($) => $['createTool.authMethod.types.apiValuePlaceholder'], {
|
||||
placeholder={t(
|
||||
($) => $['createTool.authMethod.types.apiValuePlaceholder'],
|
||||
{
|
||||
ns: 'tools',
|
||||
})!
|
||||
}
|
||||
},
|
||||
)!}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -265,11 +264,12 @@ export default function ConfigCredential({ positionCenter, credential, onChange,
|
||||
api_key_query_param: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder={
|
||||
t(($) => $['createTool.authMethod.types.queryParamPlaceholder'], {
|
||||
placeholder={t(
|
||||
($) => $['createTool.authMethod.types.queryParamPlaceholder'],
|
||||
{
|
||||
ns: 'tools',
|
||||
})!
|
||||
}
|
||||
},
|
||||
)!}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -281,11 +281,12 @@ export default function ConfigCredential({ positionCenter, credential, onChange,
|
||||
onChange={(e) =>
|
||||
setTempCredential({ ...tempCredential, api_key_value: e.target.value })
|
||||
}
|
||||
placeholder={
|
||||
t(($) => $['createTool.authMethod.types.apiValuePlaceholder'], {
|
||||
placeholder={t(
|
||||
($) => $['createTool.authMethod.types.apiValuePlaceholder'],
|
||||
{
|
||||
ns: 'tools',
|
||||
})!
|
||||
}
|
||||
},
|
||||
)!}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -256,9 +256,9 @@ const EditCustomCollectionModal: FC<Props> = ({
|
||||
/>
|
||||
<Input
|
||||
className="h-10 grow"
|
||||
placeholder={
|
||||
t(($) => $['createTool.toolNamePlaceHolder'], { ns: 'tools' })!
|
||||
}
|
||||
placeholder={t(($) => $['createTool.toolNamePlaceHolder'], {
|
||||
ns: 'tools',
|
||||
})!}
|
||||
value={customCollection.provider}
|
||||
onChange={(e) => {
|
||||
const newCollection = produce(customCollection, (draft) => {
|
||||
@@ -298,9 +298,9 @@ const EditCustomCollectionModal: FC<Props> = ({
|
||||
className="h-[240px] resize-none"
|
||||
value={schema}
|
||||
onValueChange={(value) => setSchema(value)}
|
||||
placeholder={
|
||||
t(($) => $['createTool.schemaPlaceHolder'], { ns: 'tools' })!
|
||||
}
|
||||
placeholder={t(($) => $['createTool.schemaPlaceHolder'], {
|
||||
ns: 'tools',
|
||||
})!}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -355,11 +355,12 @@ export function WorkflowToolDrawer({
|
||||
<input
|
||||
type="text"
|
||||
className="w-full appearance-none bg-transparent text-[13px] leading-[18px] font-normal text-text-secondary caret-primary-600 outline-hidden placeholder:text-text-quaternary"
|
||||
placeholder={
|
||||
t(($) => $['createTool.toolInput.descriptionPlaceholder'], {
|
||||
placeholder={t(
|
||||
($) => $['createTool.toolInput.descriptionPlaceholder'],
|
||||
{
|
||||
ns: 'tools',
|
||||
})!
|
||||
}
|
||||
},
|
||||
)!}
|
||||
value={item.description}
|
||||
onChange={(e) =>
|
||||
handleParameterChange('description', e.target.value, index)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user