Compare commits

..

24 Commits

Author SHA1 Message Date
a0d4b7efb3 test(web): typecheck permission keys query test 2026-07-07 21:57:27 +08:00
9dc017052d Merge remote-tracking branch 'origin/0707-app-context-bootstrap' into 0707-app-context-bootstrap 2026-07-07 21:51:02 +08:00
c789d052d3 fix(web): prune stale eslint suppression 2026-07-07 21:50:31 +08:00
60640519b5 [autofix.ci] apply automated fixes 2026-07-07 13:48:15 +00:00
18a9761a4a fix(web): remove unused permission keys hook 2026-07-07 21:43:22 +08:00
a4b7b6f6ba [autofix.ci] apply automated fixes 2026-07-07 13:36:03 +00:00
691aa5f31d fix(web): resolve app context style checks 2026-07-07 21:31:59 +08:00
502126516b Merge remote-tracking branch 'origin/0707-app-context-bootstrap' into 0707-app-context-bootstrap 2026-07-07 20:50:47 +08:00
da6e11d807 test(web): stabilize app context side effect assertions 2026-07-07 20:50:18 +08:00
9adcf51659 [autofix.ci] apply automated fixes 2026-07-07 12:49:12 +00:00
724ec31edb refactor(web): clarify app context bootstrap graph 2026-07-07 20:43:21 +08:00
yyh
2c6ec1a761 refactor(web): move app context layout styles to shell (#38511) 2026-07-07 09:47:30 +00:00
yyh
31b17513c2 docs(component): document focus-visible guidance (#38509) 2026-07-07 08:58:35 +00:00
fb92e9a347 chore: improve cherry pick missed message (#38496) 2026-07-07 07:53:14 +00:00
6922c45489 chore: update editor permission (#38505) 2026-07-07 07:53:01 +00:00
faaa4708a6 fix: editor should not manage member (#38503) 2026-07-07 07:39:24 +00:00
dd0c4a2296 fix: resolve 36288 mypy errors (#37850)
Co-authored-by: WH-2099 <wh2099@pm.me>
2026-07-07 06:37:20 +00:00
f3ba28463b chore: add sqlite3 to conftest (#38475) 2026-07-07 05:04:44 +00:00
800c9f4fca test(e2e): stabilize Agent v2 external runtime checks (#38493)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-07 04:48:12 +00:00
5a342f9258 feat(mcp): support MCP protocol 2025-06-18 for workflow-as-MCP server (version negotiation + structured output) (#37892)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: yunlu.wen <yunlu.wen@dify.ai>
2026-07-07 03:26:50 +00:00
dbd3316615 chore: set NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW default to true (#38362)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-07-07 02:20:15 +00:00
0a3426ea38 refactor(api): clarify DSL import and plugin migration boundaries (#38483)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-07 02:06:49 +00:00
b9c7199d34 fix(web): unify detail sidebar home control (#38487) 2026-07-07 01:44:14 +00:00
fc01d112a0 refactor(api): Stop masking refresh-token service errors as 401 (#38463)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-06 18:14:54 +00:00
82 changed files with 2167 additions and 769 deletions

View File

@ -24,6 +24,7 @@ Use this as the component decision guide for Dify web. Existing code is referenc
- Search before adding UI, hooks, helpers, query utilities, or styling patterns. Reuse existing base components, feature components, hooks, utilities, and design styles when they fit.
- Follow Dify's CSS-first Tailwind v4 contract from `packages/dify-ui/README.md` and `packages/dify-ui/AGENTS.md`. Prefer design-system tokens, utilities, and radius mappings over generic Tailwind choices.
- Preserve visible keyboard focus states on the final focusable element. Prefer styled `@langgenius/dify-ui/*` controls when available, because components such as `Button` and form/control primitives carry the standard Dify UI `focus-visible` styling. Do not assume every Dify UI export provides visual focus styles: headless anatomy parts and direct Base UI re-exports such as dialog/popover/tooltip/drawer triggers usually only provide behavior and semantics. When using native `button` / `a`, custom trigger `render` props, clickable rows, icon buttons, menu-like items, or direct trigger parts, verify the rendered focusable element has a visible focus state. If it does not, add the standard Dify UI focus style: `outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid`. Do not hide outlines without an equivalent visible `focus-visible` indicator. Component-specific focus styles should follow an existing styled primitive pattern or a concrete design constraint, not a new ad hoc style.
- Group feature code by workflow, route, or ownership area with route-aligned names: components, hooks, local types, query helpers, atoms, constants, tests, and small utilities should live near the code that changes with them.
- For each feature module, keep a module-local `README.md` as a boundary note. Start with the module name, a brief one-sentence description, then split dependencies into `Internal Modules` and `External Modules` sections; keep both sections and write `None.` when one category is empty. `Internal Modules` lists modules inside the same overall feature using paths from that feature root, such as `shared/domain/runtime-status`; `External Modules` lists project modules outside the feature using paths from the web root without a `web/` prefix, such as `app/components/base/skeleton`. Omit npm packages, workspace package dependencies, and whitelisted plumbing modules. Do not copy caller-relative import paths into the README.
- Module README whitelist: `@/service/client`, `@/next/*`.

View File

@ -45,7 +45,7 @@ while IFS= read -r commit_sha; do
)
if [[ -z "$source_sha" ]]; then
error "Commit $commit_sha ($subject) is missing cherry-pick provenance. $REMEDIATION_HINT"
error "Commit $commit_sha ($subject) is missing cherry-pick provenance. $REMEDIATION_HINT If version differences prevent using git cherry-pick -x, manually add '(cherry picked from commit <sha>)' to the commit message."
failed=1
continue
fi

View File

@ -188,13 +188,13 @@ def transform_datasource_credentials(environment: str):
firecrawl_plugin_id = "langgenius/firecrawl_datasource"
jina_plugin_id = "langgenius/jina_datasource"
if environment == "online":
notion_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(notion_plugin_id)
firecrawl_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(firecrawl_plugin_id)
jina_plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(jina_plugin_id)
notion_package_identifier = plugin_migration._fetch_latest_package_identifier(notion_plugin_id)
firecrawl_package_identifier = plugin_migration._fetch_latest_package_identifier(firecrawl_plugin_id)
jina_package_identifier = plugin_migration._fetch_latest_package_identifier(jina_plugin_id)
else:
notion_plugin_unique_identifier = None
firecrawl_plugin_unique_identifier = None
jina_plugin_unique_identifier = None
notion_package_identifier = None
firecrawl_package_identifier = None
jina_package_identifier = None
oauth_credential_type = CredentialType.OAUTH2
api_key_credential_type = CredentialType.API_KEY
@ -219,9 +219,9 @@ def transform_datasource_credentials(environment: str):
installed_plugins = installer_manager.list_plugins(tenant_id)
installed_plugins_ids = [plugin.plugin_id for plugin in installed_plugins]
if notion_plugin_id not in installed_plugins_ids:
if notion_plugin_unique_identifier:
if notion_package_identifier:
# install notion plugin
PluginService.install_from_marketplace_pkg(tenant_id, [notion_plugin_unique_identifier])
PluginService.install_from_marketplace_pkg(tenant_id, [notion_package_identifier])
auth_count = 0
for notion_tenant_credential in notion_tenant_credentials:
auth_count += 1
@ -279,9 +279,9 @@ def transform_datasource_credentials(environment: str):
installed_plugins = installer_manager.list_plugins(tenant_id)
installed_plugins_ids = [plugin.plugin_id for plugin in installed_plugins]
if firecrawl_plugin_id not in installed_plugins_ids:
if firecrawl_plugin_unique_identifier:
if firecrawl_package_identifier:
# install firecrawl plugin
PluginService.install_from_marketplace_pkg(tenant_id, [firecrawl_plugin_unique_identifier])
PluginService.install_from_marketplace_pkg(tenant_id, [firecrawl_package_identifier])
auth_count = 0
for firecrawl_tenant_credential in firecrawl_tenant_credentials:
@ -343,10 +343,10 @@ def transform_datasource_credentials(environment: str):
installed_plugins = installer_manager.list_plugins(tenant_id)
installed_plugins_ids = [plugin.plugin_id for plugin in installed_plugins]
if jina_plugin_id not in installed_plugins_ids:
if jina_plugin_unique_identifier:
if jina_package_identifier:
# install jina plugin
logger.debug("Installing Jina plugin %s", jina_plugin_unique_identifier)
PluginService.install_from_marketplace_pkg(tenant_id, [jina_plugin_unique_identifier])
logger.debug("Installing Jina plugin %s", jina_package_identifier)
PluginService.install_from_marketplace_pkg(tenant_id, [jina_package_identifier])
auth_count = 0
for jina_tenant_credential in jina_tenant_credentials:

View File

@ -32,7 +32,9 @@ def get_published_agent_app_feature_dict_and_user_input_form(
)
if agent is None:
raise AgentAppGeneratorError("Agent App has no bound Agent")
if not agent.active_config_snapshot_id or not agent.active_config_is_published:
# active_config_is_published means the draft has no unpublished edits; the public app
# can still read parameters from the active snapshot while a newer draft is pending.
if not agent.active_config_snapshot_id:
raise AgentAppNotPublishedError("Agent has not been published")
snapshot = db.session.scalar(

View File

@ -56,7 +56,7 @@ from models.account import Account
from services.account_service import AccountService, InvitationDetailDict, RegisterService, TenantService
from services.billing_service import BillingService
from services.entities.auth_entities import LoginFailureReason, LoginPayloadBase
from services.errors.account import AccountRegisterError
from services.errors.account import AccountRegisterError, RefreshTokenAccountNotFoundError, RefreshTokenNotFoundError
from services.errors.workspace import WorkSpaceNotAllowedCreateError, WorkspacesLimitExceededError
from services.feature_service import FeatureService
@ -359,18 +359,22 @@ class RefreshTokenApi(Resource):
try:
new_token_pair = AccountService.refresh_token(refresh_token, session=db.session)
except Unauthorized as exc:
return SimpleResultMessageResponse(result="fail", message=exc.description or "Unauthorized.").model_dump(
mode="json"
), 401
except (RefreshTokenNotFoundError, RefreshTokenAccountNotFoundError) as exc:
return SimpleResultMessageResponse(result="fail", message=str(exc)).model_dump(mode="json"), 401
# Create response with new cookies
# response-contract:ignore cookie-bearing Flask response
response = make_response(SimpleResultResponse(result="success").model_dump(mode="json"))
# Create response with new cookies
# response-contract:ignore cookie-bearing Flask response
response = make_response(SimpleResultResponse(result="success").model_dump(mode="json"))
# Update cookies with new tokens
set_csrf_token_to_cookie(request, response, new_token_pair.csrf_token)
set_access_token_to_cookie(request, response, new_token_pair.access_token)
set_refresh_token_to_cookie(request, response, new_token_pair.refresh_token)
return response
except Exception as e:
return SimpleResultMessageResponse(result="fail", message=str(e)).model_dump(mode="json"), 401
# Update cookies with new tokens
set_csrf_token_to_cookie(request, response, new_token_pair.csrf_token)
set_access_token_to_cookie(request, response, new_token_pair.access_token)
set_refresh_token_to_cookie(request, response, new_token_pair.refresh_token)
return response
def _get_account_with_case_fallback(email: str):

View File

@ -1,6 +1,6 @@
from typing import Any, Union
from flask import Response
from flask import Response, request
from flask_restx import Resource
from pydantic import BaseModel, Field, ValidationError
from sqlalchemy import select
@ -9,7 +9,7 @@ from sqlalchemy.orm import Session, sessionmaker
from controllers.common.schema import register_schema_model
from controllers.mcp import mcp_ns
from core.mcp import types as mcp_types
from core.mcp.server.streamable_http import handle_mcp_request
from core.mcp.server.streamable_http import handle_mcp_request, negotiate_protocol_version
from extensions.ext_database import db
from graphon.variables.input_entities import VariableEntity, VariableEntityType
from libs import helper
@ -68,6 +68,17 @@ class MCPAppApi(Resource):
request_id: Union[int, str] | None = args.id
mcp_request = self._parse_mcp_request(args.model_dump(exclude_none=True))
# Resolve the negotiated protocol version from the MCP-Protocol-Version header.
is_initialize = isinstance(mcp_request.root, mcp_types.InitializeRequest)
header_value = request.headers.get("MCP-Protocol-Version")
protocol_version = negotiate_protocol_version(header_value, is_initialize)
if protocol_version is None:
# A notification never receives a response, even with an unsupported header.
if isinstance(mcp_request, mcp_types.ClientNotification):
protocol_version = mcp_types.DEFAULT_NEGOTIATED_VERSION
else:
return self._protocol_version_error_response(request_id, header_value)
with sessionmaker(db.engine, expire_on_commit=False).begin() as session:
# Get MCP server and app
mcp_server, app = self._get_mcp_server_and_app(server_code, session)
@ -77,7 +88,28 @@ class MCPAppApi(Resource):
user_input_form = self._get_user_input_form(app)
# Handle notification vs request differently
return self._process_mcp_message(mcp_request, request_id, app, mcp_server, user_input_form, session)
return self._process_mcp_message(
mcp_request, request_id, app, mcp_server, user_input_form, session, protocol_version
)
def _protocol_version_error_response(
self, request_id: Union[int, str] | None, header_value: str | None
) -> Response:
"""Return a JSON-RPC error for an unsupported MCP-Protocol-Version header.
Per JSON-RPC 2.0, an error whose request id is unknown uses a null id, so we echo the
offending request's id directly (None -> null) instead of fabricating a placeholder.
"""
error_data = mcp_types.ErrorData(
code=mcp_types.INVALID_REQUEST,
message=f"Unsupported MCP-Protocol-Version: {header_value}",
)
error_response = {
"jsonrpc": "2.0",
"id": request_id,
"error": error_data.model_dump(by_alias=True, mode="json", exclude_none=True),
}
return helper.compact_generate_response(error_response)
def _get_mcp_server_and_app(self, server_code: str, session: Session) -> tuple[AppMCPServer, App]:
"""Get and validate MCP server and app in one query session"""
@ -104,12 +136,15 @@ class MCPAppApi(Resource):
mcp_server: AppMCPServer,
user_input_form: list[VariableEntity],
session: Session,
protocol_version: str,
) -> Response:
"""Process MCP message (notification or request)"""
if isinstance(mcp_request, mcp_types.ClientNotification):
return self._handle_notification(mcp_request)
else:
return self._handle_request(mcp_request, request_id, app, mcp_server, user_input_form, session)
return self._handle_request(
mcp_request, request_id, app, mcp_server, user_input_form, session, protocol_version
)
def _handle_notification(self, mcp_request: mcp_types.ClientNotification) -> Response:
"""Handle MCP notification"""
@ -127,12 +162,15 @@ class MCPAppApi(Resource):
mcp_server: AppMCPServer,
user_input_form: list[VariableEntity],
session: Session,
protocol_version: str,
) -> Response:
"""Handle MCP request"""
if request_id is None:
raise MCPRequestError(mcp_types.INVALID_REQUEST, "Request ID is required")
result = self._handle_mcp_request(app, mcp_server, mcp_request, user_input_form, session, request_id)
result = self._handle_mcp_request(
app, mcp_server, mcp_request, user_input_form, session, request_id, protocol_version
)
if result is None:
# This shouldn't happen for requests, but handle gracefully
raise MCPRequestError(mcp_types.INTERNAL_ERROR, "No response generated for request")
@ -229,6 +267,7 @@ class MCPAppApi(Resource):
user_input_form: list[VariableEntity],
session: Session,
request_id: Union[int, str],
protocol_version: str,
) -> mcp_types.JSONRPCResponse | mcp_types.JSONRPCError | None:
"""Handle MCP request and return response"""
end_user = self._retrieve_end_user(mcp_server.tenant_id, mcp_server.id)
@ -238,4 +277,6 @@ class MCPAppApi(Resource):
client_name = f"{client_info.name}@{client_info.version}"
end_user = self._create_end_user(client_name, app.tenant_id, app.id, mcp_server.id, session)
return handle_mcp_request(session, app, mcp_request, user_input_form, mcp_server, end_user, request_id)
return handle_mcp_request(
session, app, mcp_request, user_input_form, mcp_server, end_user, request_id, protocol_version
)

View File

@ -611,7 +611,9 @@ class AgentAppGenerator(MessageBasedAppGenerator):
"build_draft" if draft.draft_type == AgentConfigDraftType.DEBUG_BUILD else "draft"
)
return agent, draft.id, config_version_kind, agent_soul
if not agent.active_config_snapshot_id or not agent.active_config_is_published:
# active_config_is_published tracks whether the editable draft matches the active snapshot.
# Public runtime must keep serving the active snapshot even when unpublished draft edits exist.
if not agent.active_config_snapshot_id:
raise AgentAppNotPublishedError("Agent has not been published")
_, snapshot, agent_soul = self._resolve_agent_by_id(
tenant_id=app_model.tenant_id,

View File

@ -13,7 +13,7 @@ from core.helper.code_executor.jinja2.jinja2_transformer import Jinja2TemplateTr
from core.helper.code_executor.python3.python3_transformer import Python3TemplateTransformer
from core.helper.code_executor.template_transformer import TemplateTransformer
from core.helper.http_client_pooling import get_pooled_http_client
from graphon.nodes.code.entities import CodeLanguage
from graphon.nodes.code.entities import CodeLanguage as CodeLanguage # noqa: PLC0414
logger = logging.getLogger(__name__)
code_execution_endpoint_url = URL(str(dify_config.CODE_EXECUTION_ENDPOINT))
@ -133,7 +133,9 @@ class CodeExecutor:
return response_code.data.stdout or ""
@classmethod
def execute_workflow_code_template(cls, language: CodeLanguage, code: str, inputs: Mapping[str, Any]):
def execute_workflow_code_template(
cls, language: CodeLanguage, code: str, inputs: Mapping[str, Any]
) -> dict[str, Any]:
"""
Execute code
:param language: code language

View File

@ -11,7 +11,7 @@ class Jinja2TemplateTransformer(TemplateTransformer):
@classmethod
@override
def transform_response(cls, response: str):
def transform_response(cls, response: str) -> dict[str, Any]:
"""
Transform response to dict
:param response: response

View File

@ -36,14 +36,14 @@ class TemplateTransformer(ABC):
return runner_script, preload_script
@classmethod
def extract_result_str_from_response(cls, response: str):
def extract_result_str_from_response(cls, response: str) -> str:
result = re.search(rf"{cls._result_tag}(.*){cls._result_tag}", response, re.DOTALL)
if not result:
raise ValueError(f"Failed to parse result: no result tag found in response. Response: {response[:200]}...")
return result.group(1)
@classmethod
def transform_response(cls, response: str) -> Mapping[str, Any]:
def transform_response(cls, response: str) -> dict[str, Any]:
"""
Transform response to dict
:param response: response
@ -71,7 +71,7 @@ class TemplateTransformer(ABC):
return result
@classmethod
def _post_process_result(cls, result: dict[Any, Any]) -> dict[Any, Any]:
def _post_process_result(cls, result: dict[str, Any]) -> dict[str, Any]:
"""
Post-process the result to convert scientific notation strings back to numbers
"""
@ -89,7 +89,7 @@ class TemplateTransformer(ABC):
return [convert_scientific_notation(v) for v in value]
return value
return convert_scientific_notation(result)
return {key: convert_scientific_notation(value) for key, value in result.items()}
@classmethod
@abstractmethod

View File

@ -24,7 +24,7 @@ def upload_dsl(dsl_file_bytes: bytes, filename: str = "template.yaml") -> str:
response.raise_for_status()
data = response.json()
claim_code = data.get("data", {}).get("claim_code")
if not claim_code:
if not isinstance(claim_code, str) or not claim_code:
raise ValueError("Creators Platform did not return a valid claim_code")
return claim_code

View File

@ -45,7 +45,7 @@ def is_credential_exists(credential_id: str, credential_type: "PluginCredentialT
def runtime_check_credential_policy_compliance(
credential_id: str, provider: str, credential_type: "PluginCredentialType", check_existence: bool = True
):
) -> None:
if dify_config.ENTERPRISE_DISABLE_RUNTIME_CREDENTIAL_CHECK:
return
check_credential_policy_compliance(

View File

@ -1,4 +1,7 @@
def download_with_size_limit(url, max_download_size: int, **kwargs):
from typing import Any
def download_with_size_limit(url: str, max_download_size: int, **kwargs: Any) -> bytes:
from core.file import remote_fetcher
response = remote_fetcher.make_request("GET", url, follow_redirects=True, **kwargs)

View File

@ -1,5 +1,7 @@
import base64
from Crypto.PublicKey import RSA
from libs import rsa
@ -11,13 +13,13 @@ def obfuscated_token(token: str) -> str:
return token[:6] + "*" * 12 + token[-2:]
def full_mask_token(token_length=20):
def full_mask_token(token_length: int = 20) -> str:
return "*" * token_length
def encrypt_token(tenant_id: str, token: str):
from extensions.ext_database import db
def encrypt_token(tenant_id: str, token: str) -> str:
from models.account import Tenant
from models.engine import db
if not (tenant := db.session.get(Tenant, tenant_id)):
raise ValueError(f"Tenant with id {tenant_id} not found")
@ -30,15 +32,15 @@ def decrypt_token(tenant_id: str, token: str) -> str:
return rsa.decrypt(base64.b64decode(token), tenant_id)
def batch_decrypt_token(tenant_id: str, tokens: list[str]):
def batch_decrypt_token(tenant_id: str, tokens: list[str]) -> list[str]:
rsa_key, cipher_rsa = rsa.get_decrypt_decoding(tenant_id)
return [rsa.decrypt_token_with_decoding(base64.b64decode(token), rsa_key, cipher_rsa) for token in tokens]
def get_decrypt_decoding(tenant_id: str):
def get_decrypt_decoding(tenant_id: str) -> tuple[RSA.RsaKey, object]:
return rsa.get_decrypt_decoding(tenant_id)
def decrypt_token_with_decoding(token: str, rsa_key, cipher_rsa):
def decrypt_token_with_decoding(token: str, rsa_key: RSA.RsaKey, cipher_rsa: object) -> str:
return rsa.decrypt_token_with_decoding(base64.b64decode(token), rsa_key, cipher_rsa)

View File

@ -1,5 +1,6 @@
import logging
from collections.abc import Sequence
from typing import Any
from urllib.parse import urlencode
import httpx
@ -21,7 +22,7 @@ def get_plugin_pkg_url(plugin_unique_identifier: str) -> str:
return f"{marketplace_api_url / 'api/v1/plugins/download'}?{query}"
def download_plugin_pkg(plugin_unique_identifier: str):
def download_plugin_pkg(plugin_unique_identifier: str) -> bytes:
return download_with_size_limit(get_plugin_pkg_url(plugin_unique_identifier), dify_config.PLUGIN_MAX_PACKAGE_SIZE)
@ -41,7 +42,7 @@ def batch_fetch_plugin_manifests(plugin_ids: list[str]) -> Sequence[MarketplaceP
return [MarketplacePluginDeclaration.model_validate(plugin) for plugin in response.json()["data"]["plugins"]]
def batch_fetch_plugin_by_ids(plugin_ids: list[str]) -> list[dict]:
def batch_fetch_plugin_by_ids(plugin_ids: list[str]) -> list[dict[str, Any]]:
if not plugin_ids:
return []
@ -55,10 +56,19 @@ def batch_fetch_plugin_by_ids(plugin_ids: list[str]) -> list[dict]:
response.raise_for_status()
data = response.json()
return data.get("data", {}).get("plugins", [])
plugins = data.get("data", {}).get("plugins", [])
if not isinstance(plugins, list):
raise ValueError("Marketplace did not return a valid plugins list")
result: list[dict[str, Any]] = []
for plugin in plugins:
if not isinstance(plugin, dict) or not all(isinstance(key, str) for key in plugin):
raise ValueError("Marketplace did not return a valid plugins list")
result.append(plugin)
return result
def record_install_plugin_event(plugin_unique_identifier: str):
def record_install_plugin_event(plugin_unique_identifier: str) -> None:
url = str(marketplace_api_url / "api/v1/stats/plugins/install_count")
response = httpx.post(url, json={"unique_identifier": plugin_unique_identifier}, timeout=MARKETPLACE_TIMEOUT)
response.raise_for_status()

View File

@ -34,7 +34,7 @@ class ProviderCredentialsCache:
else:
return None
def set(self, credentials: dict[str, Any]):
def set(self, credentials: dict[str, Any]) -> None:
"""
Cache model provider credentials.
@ -43,7 +43,7 @@ class ProviderCredentialsCache:
"""
redis_client.setex(self.cache_key, 86400, json.dumps(credentials))
def delete(self):
def delete(self) -> None:
"""
Delete cached model provider credentials.

View File

@ -20,17 +20,18 @@ def import_module_from_source[T: (str, bytes)](
raise Exception(f"Failed to load module {module_name} from {py_file_path!r}")
else:
# Refer to: https://docs.python.org/3/library/importlib.html#importing-a-source-file-directly
# FIXME: mypy does not support the type of spec.loader
spec = importlib.util.spec_from_file_location(module_name, py_file_path) # type: ignore[assignment]
if not spec or not spec.loader:
new_spec = importlib.util.spec_from_file_location(module_name, py_file_path)
if not new_spec or not new_spec.loader:
raise Exception(f"Failed to load module {module_name} from {py_file_path!r}")
if use_lazy_loader:
# Refer to: https://docs.python.org/3/library/importlib.html#implementing-lazy-imports
spec.loader = importlib.util.LazyLoader(spec.loader)
new_spec.loader = importlib.util.LazyLoader(new_spec.loader)
spec = new_spec
module = importlib.util.module_from_spec(spec)
if not existed_spec:
sys.modules[module_name] = module
spec.loader.exec_module(module)
if spec.loader is not None:
spec.loader.exec_module(module)
return module
except Exception as e:
logger.exception("Failed to load module %s from script file '%s'", module_name, repr(py_file_path))

View File

@ -9,11 +9,11 @@ from extensions.ext_redis import redis_client
class ProviderCredentialsCache(ABC):
"""Base class for provider credentials cache"""
def __init__(self, **kwargs):
def __init__(self, **kwargs: Any) -> None:
self.cache_key = self._generate_cache_key(**kwargs)
@abstractmethod
def _generate_cache_key(self, **kwargs) -> str:
def _generate_cache_key(self, **kwargs: Any) -> str:
"""Generate cache key based on subclass implementation"""
pass
@ -28,11 +28,11 @@ class ProviderCredentialsCache(ABC):
return None
return None
def set(self, config: dict[str, Any]):
def set(self, config: dict[str, Any]) -> None:
"""Cache provider credentials"""
redis_client.setex(self.cache_key, 86400, json.dumps(config))
def delete(self):
def delete(self) -> None:
"""Delete cached provider credentials"""
redis_client.delete(self.cache_key)
@ -48,7 +48,7 @@ class SingletonProviderCredentialsCache(ProviderCredentialsCache):
)
@override
def _generate_cache_key(self, **kwargs) -> str:
def _generate_cache_key(self, **kwargs: Any) -> str:
tenant_id = kwargs["tenant_id"]
provider_type = kwargs["provider_type"]
identity_name = kwargs["provider_identity"]
@ -63,7 +63,7 @@ class ToolProviderCredentialsCache(ProviderCredentialsCache):
super().__init__(tenant_id=tenant_id, provider=provider, credential_id=credential_id)
@override
def _generate_cache_key(self, **kwargs) -> str:
def _generate_cache_key(self, **kwargs: Any) -> str:
tenant_id = kwargs["tenant_id"]
provider = kwargs["provider"]
credential_id = kwargs["credential_id"]
@ -77,10 +77,10 @@ class NoOpProviderCredentialCache:
"""Get cached provider credentials"""
return None
def set(self, config: dict[str, Any]):
def set(self, config: dict[str, Any]) -> None:
"""Cache provider credentials"""
pass
def delete(self):
def delete(self) -> None:
"""Delete cached provider credentials"""
pass

View File

@ -125,5 +125,7 @@ class ProviderConfigEncrypter:
return data
def create_provider_encrypter(tenant_id: str, config: list[BasicProviderConfig], cache: ProviderConfigCache):
def create_provider_encrypter(
tenant_id: str, config: list[BasicProviderConfig], cache: ProviderConfigCache
) -> tuple[ProviderConfigEncrypter, ProviderConfigCache]:
return ProviderConfigEncrypter(tenant_id=tenant_id, config=config, provider_config_cache=cache), cache

View File

@ -37,11 +37,11 @@ class ToolParameterCache:
else:
return None
def set(self, parameters: dict[str, Any]):
def set(self, parameters: dict[str, Any]) -> None:
"""Cache model provider credentials."""
redis_client.setex(self.cache_key, 86400, json.dumps(parameters))
def delete(self):
def delete(self) -> None:
"""
Delete cached model provider credentials.

View File

@ -61,7 +61,7 @@ def get_external_trace_id(request: Any) -> str | None:
return None
def extract_external_trace_id_from_args(args: Mapping[str, Any]):
def extract_external_trace_id_from_args(args: Mapping[str, Any]) -> dict[str, Any]:
"""
Extract 'external_trace_id' from args.

View File

@ -15,6 +15,36 @@ from services.app_generate_service import AppGenerateService
logger = logging.getLogger(__name__)
# Structured tool output (outputSchema + structuredContent) was introduced in MCP 2025-06-18.
STRUCTURED_OUTPUT_MIN_VERSION = "2025-06-18"
def _supports_structured_output(protocol_version: str) -> bool:
"""Return True when the negotiated protocol version supports structured tool output.
MCP protocol versions are YYYY-MM-DD strings, so lexical comparison equals chronological.
"""
return protocol_version >= STRUCTURED_OUTPUT_MIN_VERSION
def negotiate_protocol_version(header_value: str | None, is_initialize: bool) -> str | None:
"""Resolve the negotiated protocol version for an incoming MCP request.
The version is taken from the MCP-Protocol-Version header on post-initialize requests.
Returns the version to use for behavior gating, or None when the client sent an explicit
but unsupported header (the caller should reply with a JSON-RPC INVALID_REQUEST error).
Initialize requests negotiate via the request body, so they always receive
DEFAULT_NEGOTIATED_VERSION and their header is never validated or rejected.
"""
if is_initialize:
return mcp_types.DEFAULT_NEGOTIATED_VERSION
# Treat an absent or empty header as "not specified" -> default version.
if not header_value:
return mcp_types.DEFAULT_NEGOTIATED_VERSION
if header_value not in mcp_types.SERVER_SUPPORTED_PROTOCOL_VERSIONS:
return None
return header_value
class ToolParameterSchemaDict(TypedDict):
type: str
@ -35,6 +65,7 @@ def handle_mcp_request(
mcp_server: AppMCPServer,
end_user: EndUser | None = None,
request_id: int | str = 1,
protocol_version: str = mcp_types.DEFAULT_NEGOTIATED_VERSION,
) -> mcp_types.JSONRPCResponse | mcp_types.JSONRPCError:
"""
Handle MCP request and return JSON-RPC response
@ -77,15 +108,24 @@ def handle_mcp_request(
# Dispatch request to appropriate handler based on instance type
match request_root:
case mcp_types.InitializeRequest():
return create_success_response(handle_initialize(mcp_server.description))
return create_success_response(
handle_initialize(mcp_server.description, request_root.params.protocolVersion)
)
case mcp_types.ListToolsRequest():
return create_success_response(
handle_list_tools(
app.name, app.mode, user_input_form, mcp_server.description, mcp_server.parameters_dict
app.name,
app.mode,
user_input_form,
mcp_server.description,
mcp_server.parameters_dict,
protocol_version,
)
)
case mcp_types.CallToolRequest():
return create_success_response(handle_call_tool(session, app, request, user_input_form, end_user))
return create_success_response(
handle_call_tool(session, app, request, user_input_form, end_user, protocol_version)
)
case mcp_types.PingRequest():
return create_success_response(handle_ping())
case _:
@ -104,14 +144,22 @@ def handle_ping() -> mcp_types.EmptyResult:
return mcp_types.EmptyResult()
def handle_initialize(description: str) -> mcp_types.InitializeResult:
"""Handle initialize request"""
def handle_initialize(description: str, requested_version: str | int) -> mcp_types.InitializeResult:
"""Handle initialize request, negotiating the protocol version with the client.
Echoes the client's requested version when the server supports it, otherwise returns the
server's latest supported version (per the MCP lifecycle spec).
"""
negotiated_version: str = mcp_types.SERVER_LATEST_PROTOCOL_VERSION
if isinstance(requested_version, str) and requested_version in mcp_types.SERVER_SUPPORTED_PROTOCOL_VERSIONS:
negotiated_version = requested_version
capabilities = mcp_types.ServerCapabilities(
tools=mcp_types.ToolsCapability(listChanged=False),
)
return mcp_types.InitializeResult(
protocolVersion=mcp_types.SERVER_LATEST_PROTOCOL_VERSION,
protocolVersion=negotiated_version,
capabilities=capabilities,
serverInfo=mcp_types.Implementation(name="Dify", version=dify_config.project.version),
instructions=description,
@ -124,19 +172,23 @@ def handle_list_tools(
user_input_form: list[VariableEntity],
description: str,
parameters_dict: dict[str, str],
protocol_version: str = mcp_types.DEFAULT_NEGOTIATED_VERSION,
) -> mcp_types.ListToolsResult:
"""Handle list tools request"""
parameter_schema = build_parameter_schema(app_mode, user_input_form, parameters_dict)
supports_structured = _supports_structured_output(protocol_version)
return mcp_types.ListToolsResult(
tools=[
mcp_types.Tool(
name=app_name,
description=description,
inputSchema=cast(dict[str, Any], parameter_schema),
)
],
# For 2025-06-18+ clients, expose an explicit display title and a permissive output
# schema. Both stay None (and are stripped by exclude_none serialization) for older
# clients, so their tool definition is unchanged.
tool = mcp_types.Tool(
name=app_name,
title=app_name if supports_structured else None,
description=description,
inputSchema=cast(dict[str, Any], parameter_schema),
outputSchema={"type": "object"} if supports_structured else None,
)
return mcp_types.ListToolsResult(tools=[tool])
def handle_call_tool(
@ -145,6 +197,7 @@ def handle_call_tool(
request: mcp_types.ClientRequest,
user_input_form: list[VariableEntity],
end_user: EndUser | None,
protocol_version: str = mcp_types.DEFAULT_NEGOTIATED_VERSION,
) -> mcp_types.CallToolResult:
"""Handle call tool request"""
request_obj = cast(mcp_types.CallToolRequest, request.root)
@ -163,7 +216,13 @@ def handle_call_tool(
)
answer = extract_answer_from_response(app, response)
return mcp_types.CallToolResult(content=[mcp_types.TextContent(text=answer, type="text")])
structured_content = None
if _supports_structured_output(protocol_version):
structured_content = extract_structured_output(app, response, answer)
return mcp_types.CallToolResult(
content=[mcp_types.TextContent(text=answer, type="text")],
structuredContent=structured_content,
)
def build_parameter_schema(
@ -204,6 +263,29 @@ def prepare_tool_arguments(app: App, arguments: dict[str, Any]) -> ToolArguments
return {"query": query, "inputs": args_copy}
def extract_structured_output(app: App, response: Any, answer: str) -> dict[str, Any] | None:
"""Build MCP structured tool output (2025-06-18) from the app response.
WORKFLOW mode exposes the raw outputs mapping; chat/agent/completion modes expose the
answer string under an "answer" key. Returns None when no structured output is available.
"""
match app.mode:
case AppMode.WORKFLOW:
if isinstance(response, Mapping):
data = response.get("data")
if isinstance(data, Mapping):
outputs = data.get("outputs")
# All three guards use Mapping for consistency; coerce to a concrete dict
# because structuredContent must be a JSON object (dict[str, Any]).
if isinstance(outputs, Mapping):
return dict(outputs)
return None
case AppMode.ADVANCED_CHAT | AppMode.CHAT | AppMode.AGENT_CHAT | AppMode.COMPLETION:
return {"answer": answer}
case _:
return None
def extract_answer_from_response(app: App, response: Any) -> str:
"""Extract answer from app generate response"""
answer = ""

View File

@ -22,10 +22,13 @@ for reference.
* Define additional model classes instead of using dictionaries. Do this even if they're
not separate types in the schema.
"""
# Client support both version, not support 2025-06-18 yet.
# Latest protocol version the Dify MCP client negotiates with upstream MCP servers.
LATEST_PROTOCOL_VERSION = "2025-06-18"
# Server support 2024-11-05 to allow claude to use.
SERVER_LATEST_PROTOCOL_VERSION = "2024-11-05"
# Latest protocol version the Dify MCP server advertises to connecting clients.
SERVER_LATEST_PROTOCOL_VERSION = "2025-06-18"
# Protocol versions the Dify MCP server can negotiate down to (e.g. Claude on 2024-11-05).
SERVER_SUPPORTED_PROTOCOL_VERSIONS: frozenset[str] = frozenset({"2024-11-05", "2025-03-26", "2025-06-18"})
# Version assumed when a client omits the MCP-Protocol-Version header on post-initialize requests.
DEFAULT_NEGOTIATED_VERSION = "2025-03-26"
ProgressToken = str | int
Cursor = str

View File

@ -228,7 +228,7 @@ class CredentialType(enum.StrEnum):
OAUTH2 = "oauth2"
UNAUTHORIZED = "unauthorized"
def get_name(self):
def get_name(self) -> str:
if self == CredentialType.API_KEY:
return "API KEY"
elif self == CredentialType.OAUTH2:

View File

@ -33,7 +33,6 @@ from models.dataset import Dataset, DatasetCollectionBinding
if TYPE_CHECKING:
from qdrant_client.conversions import common_types
from qdrant_client.http import models as rest
type DictFilter = dict[str, str | int | bool | dict | list]
type MetadataFilter = DictFilter | common_types.Filter

View File

@ -41,7 +41,6 @@ from models.enums import TidbAuthBindingStatus
if TYPE_CHECKING:
from qdrant_client import grpc # noqa
from qdrant_client.conversions import common_types
from qdrant_client.http import models as rest
type DictFilter = dict[str, str | int | bool | dict | list]
type MetadataFilter = DictFilter | common_types.Filter

View File

@ -65,6 +65,8 @@ from services.errors.account import (
LinkAccountIntegrateError,
MemberNotInTenantError,
NoPermissionError,
RefreshTokenAccountNotFoundError,
RefreshTokenNotFoundError,
RoleAlreadyAssignedError,
TenantNotFoundError,
)
@ -654,11 +656,11 @@ class AccountService:
# Verify the refresh token
account_id = redis_client.get(AccountService._get_refresh_token_key(refresh_token))
if not account_id:
raise ValueError("Invalid refresh token")
raise RefreshTokenNotFoundError("Invalid refresh token")
account = AccountService.load_user(account_id.decode("utf-8"), session)
if not account:
raise ValueError("Invalid account")
raise RefreshTokenAccountNotFoundError("Invalid account")
# Generate new access token and refresh token
new_access_token = AccountService.get_account_jwt_token(account)

View File

@ -39,6 +39,7 @@ from libs.datetime_utils import naive_utc_now
from models import Account, App, AppMode
from models.model import AppModelConfig, AppModelConfigDict, IconType
from models.workflow import Workflow
from services.dsl_content import DSL_MAX_SIZE, dsl_content_size
from services.dsl_version import check_version_compatibility
from services.entities.dsl_entities import CheckDependenciesResult, ImportMode, ImportStatus
from services.errors.app import WorkflowNotFoundError
@ -51,7 +52,6 @@ logger = logging.getLogger(__name__)
IMPORT_INFO_REDIS_KEY_PREFIX = "app_import_info:"
CHECK_DEPENDENCIES_REDIS_KEY_PREFIX = "app_check_dependencies:"
IMPORT_INFO_REDIS_EXPIRY = 10 * 60 # 10 minutes
DSL_MAX_SIZE = 10 * 1024 * 1024 # 10MB
CURRENT_DSL_VERSION = CURRENT_APP_DSL_VERSION
@ -131,15 +131,16 @@ class AppDslService:
yaml_url = yaml_url.replace("/blob/", "/")
response = remote_fetcher.make_request("GET", yaml_url.strip(), follow_redirects=True, timeout=(10, 10))
response.raise_for_status()
content = response.content.decode()
raw_content = response.content
if len(content) > DSL_MAX_SIZE:
if dsl_content_size(raw_content) > DSL_MAX_SIZE:
return Import(
id=import_id,
status=ImportStatus.FAILED,
error="File size exceeds the limit of 10MB",
)
content = raw_content.decode("utf-8")
if not content:
return Import(
id=import_id,
@ -160,6 +161,12 @@ class AppDslService:
error="yaml_content is required when import_mode is yaml-content",
)
content = yaml_content
if dsl_content_size(content) > DSL_MAX_SIZE:
return Import(
id=import_id,
status=ImportStatus.FAILED,
error="File size exceeds the limit of 10MB",
)
# Process YAML content
try:

View File

@ -0,0 +1,9 @@
"""Shared DSL content size and decoding rules."""
DSL_MAX_SIZE = 10 * 1024 * 1024 # 10MB
def dsl_content_size(content: str | bytes) -> int:
if isinstance(content, bytes):
return len(content)
return len(content.encode("utf-8"))

View File

@ -365,7 +365,6 @@ _LEGACY_WORKSPACE_ADMIN_KEYS: list[str] = [
]
_LEGACY_WORKSPACE_EDITOR_KEYS: list[str] = [
"workspace.member.manage",
"api_extension.manage",
"plugin.install",
"credential.use",

View File

@ -17,6 +17,14 @@ class AccountPasswordError(BaseServiceError):
pass
class RefreshTokenNotFoundError(BaseServiceError):
pass
class RefreshTokenAccountNotFoundError(BaseServiceError):
pass
class AccountNotLinkTenantError(BaseServiceError):
pass

View File

@ -307,9 +307,9 @@ class PluginMigration:
return result
@classmethod
def _fetch_plugin_unique_identifier(cls, plugin_id: str) -> str | None:
def _fetch_latest_package_identifier(cls, plugin_id: str) -> str | None:
"""
Fetch plugin unique identifier using plugin id.
Fetch the latest marketplace package identifier using a plugin id.
"""
if not dify_config.MARKETPLACE_ENABLED:
return None
@ -328,7 +328,7 @@ class PluginMigration:
@classmethod
def extract_unique_plugins(cls, extracted_plugins: str) -> ExtractedPluginsDict:
plugins: dict[str, str] = {}
package_identifier_by_plugin_id: dict[str, str] = {}
plugin_ids = []
plugin_not_exist = []
logger.info("Extracting unique plugins from %s", extracted_plugins)
@ -341,19 +341,19 @@ class PluginMigration:
def fetch_plugin(plugin_id):
try:
unique_identifier = cls._fetch_plugin_unique_identifier(plugin_id)
if unique_identifier:
plugins[plugin_id] = unique_identifier
latest_package_identifier = cls._fetch_latest_package_identifier(plugin_id)
if latest_package_identifier:
package_identifier_by_plugin_id[plugin_id] = latest_package_identifier
else:
plugin_not_exist.append(plugin_id)
except Exception:
logger.exception("Failed to fetch plugin unique identifier for %s", plugin_id)
logger.exception("Failed to fetch latest package identifier for %s", plugin_id)
plugin_not_exist.append(plugin_id)
with ThreadPoolExecutor(max_workers=10) as executor:
list(tqdm.tqdm(executor.map(fetch_plugin, plugin_ids), total=len(plugin_ids)))
return {"plugins": plugins, "plugin_not_exist": plugin_not_exist}
return {"plugins": package_identifier_by_plugin_id, "plugin_not_exist": plugin_not_exist}
@classmethod
def install_plugins(cls, extracted_plugins: str, output_file: str, workers: int = 100):
@ -362,17 +362,22 @@ class PluginMigration:
"""
manager = PluginInstaller()
plugins = cls.extract_unique_plugins(extracted_plugins)
extracted = cls.extract_unique_plugins(extracted_plugins)
package_identifier_by_plugin_id = extracted["plugins"]
not_installed = []
plugin_install_failed = []
# use a fake tenant id to install all the plugins
fake_tenant_id = uuid4().hex
logger.info("Installing %s plugin instances for fake tenant %s", len(plugins["plugins"]), fake_tenant_id)
logger.info(
"Installing %s plugin instances for fake tenant %s",
len(package_identifier_by_plugin_id),
fake_tenant_id,
)
thread_pool = ThreadPoolExecutor(max_workers=workers)
response = cls.handle_plugin_instance_install(fake_tenant_id, plugins["plugins"])
response = cls.handle_plugin_instance_install(fake_tenant_id, package_identifier_by_plugin_id)
if response.get("failed"):
plugin_install_failed.extend(response.get("failed", []))
@ -384,21 +389,21 @@ class PluginMigration:
# at most 64 plugins one batch
for i in range(0, len(plugin_ids), 64):
batch_plugin_ids = plugin_ids[i : i + 64]
batch_plugin_identifiers = [
plugins["plugins"][plugin_id]
batch_package_identifiers = [
package_identifier_by_plugin_id[plugin_id]
for plugin_id in batch_plugin_ids
if plugin_id not in installed_plugins_ids and plugin_id in plugins["plugins"]
if plugin_id not in installed_plugins_ids and plugin_id in package_identifier_by_plugin_id
]
if batch_plugin_identifiers:
if batch_package_identifiers:
manager.install_from_identifiers(
tenant_id,
batch_plugin_identifiers,
batch_package_identifiers,
PluginInstallationSource.Marketplace,
metas=[
{
"plugin_unique_identifier": identifier,
"plugin_unique_identifier": package_identifier,
}
for identifier in batch_plugin_identifiers
for package_identifier in batch_package_identifiers
],
)
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
@ -412,10 +417,8 @@ class PluginMigration:
tenant_id = data["tenant_id"]
plugin_ids = data["plugins"]
plugin_not_exist: list[str] = []
# get plugin unique identifier
for plugin_id in plugin_ids:
unique_identifier = plugins.get(plugin_id)
if unique_identifier:
if plugin_id not in package_identifier_by_plugin_id:
plugin_not_exist.append(plugin_id)
if plugin_not_exist:
@ -459,36 +462,44 @@ class PluginMigration:
"""
manager = PluginInstaller()
plugins = cls.extract_unique_plugins(extracted_plugins)
extracted = cls.extract_unique_plugins(extracted_plugins)
package_identifier_by_plugin_id = extracted["plugins"]
plugin_install_failed = []
# use a fake tenant id to install all the plugins
fake_tenant_id = uuid4().hex
logger.info("Installing %s plugin instances for fake tenant %s", len(plugins["plugins"]), fake_tenant_id)
logger.info(
"Installing %s plugin instances for fake tenant %s",
len(package_identifier_by_plugin_id),
fake_tenant_id,
)
thread_pool = ThreadPoolExecutor(max_workers=workers)
response = cls.handle_plugin_instance_install(fake_tenant_id, plugins["plugins"])
response = cls.handle_plugin_instance_install(fake_tenant_id, package_identifier_by_plugin_id)
if response.get("failed"):
plugin_install_failed.extend(response.get("failed", []))
def install(
tenant_id: str, plugin_ids: dict[str, str], total_success_tenant: int, total_failed_tenant: int
tenant_id: str,
package_identifier_by_plugin_id: dict[str, str],
total_success_tenant: int,
total_failed_tenant: int,
) -> None:
logger.info("Installing %s plugins for tenant %s", len(plugin_ids), tenant_id)
logger.info("Installing %s plugins for tenant %s", len(package_identifier_by_plugin_id), tenant_id)
try:
# fetch plugin already installed
installed_plugins = manager.list_plugins(tenant_id)
installed_plugins_ids = [plugin.plugin_id for plugin in installed_plugins]
# at most 64 plugins one batch
for i in range(0, len(plugin_ids), 64):
batch_plugin_ids = list(plugin_ids.keys())[i : i + 64]
batch_plugin_identifiers = [
plugin_ids[plugin_id]
for i in range(0, len(package_identifier_by_plugin_id), 64):
batch_plugin_ids = list(package_identifier_by_plugin_id.keys())[i : i + 64]
batch_package_identifiers = [
package_identifier_by_plugin_id[plugin_id]
for plugin_id in batch_plugin_ids
if plugin_id not in installed_plugins_ids and plugin_id in plugin_ids
if plugin_id not in installed_plugins_ids and plugin_id in package_identifier_by_plugin_id
]
PluginService.install_from_marketplace_pkg(tenant_id, batch_plugin_identifiers)
PluginService.install_from_marketplace_pkg(tenant_id, batch_package_identifiers)
total_success_tenant += 1
except Exception:
@ -510,7 +521,7 @@ class PluginMigration:
thread_pool.submit(
install,
tenant_id,
plugins.get("plugins", {}),
package_identifier_by_plugin_id,
total_success_tenant,
total_failed_tenant,
)
@ -542,12 +553,12 @@ class PluginMigration:
@classmethod
def handle_plugin_instance_install(
cls, tenant_id: str, plugin_identifiers_map: Mapping[str, str]
cls, tenant_id: str, package_identifier_by_plugin_id: Mapping[str, str]
) -> PluginInstallResultDict:
"""
Install plugins for a tenant.
"""
if plugin_identifiers_map and not dify_config.MARKETPLACE_ENABLED:
if package_identifier_by_plugin_id and not dify_config.MARKETPLACE_ENABLED:
raise ValueError(
"Marketplace disabled in offline mode; cannot bulk-install plugins. "
"Pre-upload plugin packages via Console first."
@ -558,17 +569,17 @@ class PluginMigration:
thread_pool = ThreadPoolExecutor(max_workers=10)
futures = []
for plugin_id, plugin_identifier in plugin_identifiers_map.items():
for plugin_id, package_identifier in package_identifier_by_plugin_id.items():
def download_and_upload(tenant_id, plugin_id, plugin_identifier):
plugin_package = marketplace.download_plugin_pkg(plugin_identifier)
def download_and_upload(tenant_id, plugin_id, package_identifier):
plugin_package = marketplace.download_plugin_pkg(package_identifier)
if not plugin_package:
raise Exception(f"Failed to download plugin {plugin_identifier}")
raise Exception(f"Failed to download plugin {package_identifier}")
# upload
manager.upload_pkg(tenant_id, plugin_package, verify_signature=True)
futures.append(thread_pool.submit(download_and_upload, tenant_id, plugin_id, plugin_identifier))
futures.append(thread_pool.submit(download_and_upload, tenant_id, plugin_id, package_identifier))
# Wait for all downloads to complete
for future in futures:
@ -578,33 +589,33 @@ class PluginMigration:
success = []
failed = []
reverse_map = {v: k for k, v in plugin_identifiers_map.items()}
plugin_id_by_package_identifier = {v: k for k, v in package_identifier_by_plugin_id.items()}
# at most 8 plugins one batch
for i in range(0, len(plugin_identifiers_map), 8):
batch_plugin_ids = list(plugin_identifiers_map.keys())[i : i + 8]
batch_plugin_identifiers = [plugin_identifiers_map[plugin_id] for plugin_id in batch_plugin_ids]
for i in range(0, len(package_identifier_by_plugin_id), 8):
batch_plugin_ids = list(package_identifier_by_plugin_id.keys())[i : i + 8]
batch_package_identifiers = [package_identifier_by_plugin_id[plugin_id] for plugin_id in batch_plugin_ids]
try:
response = manager.install_from_identifiers(
tenant_id=tenant_id,
identifiers=batch_plugin_identifiers,
identifiers=batch_package_identifiers,
source=PluginInstallationSource.Marketplace,
metas=[
{
"plugin_unique_identifier": identifier,
"plugin_unique_identifier": package_identifier,
}
for identifier in batch_plugin_identifiers
for package_identifier in batch_package_identifiers
],
)
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
except Exception:
# add to failed
failed.extend(batch_plugin_identifiers)
failed.extend(batch_plugin_ids)
continue
if response.all_installed:
success.extend(batch_plugin_identifiers)
success.extend(batch_plugin_ids)
continue
task_id = response.task_id
@ -614,10 +625,13 @@ class PluginMigration:
if status.status in [PluginInstallTaskStatus.Failed, PluginInstallTaskStatus.Success]:
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
for plugin in status.plugins:
plugin_id = plugin_id_by_package_identifier.get(
plugin.plugin_unique_identifier, plugin.plugin_unique_identifier.split(":", 1)[0]
)
if plugin.status == PluginInstallTaskStatus.Success:
success.append(reverse_map[plugin.plugin_unique_identifier])
success.append(plugin_id)
else:
failed.append(reverse_map[plugin.plugin_unique_identifier])
failed.append(plugin_id)
logger.error(
"Failed to install plugin %s, error: %s",
plugin.plugin_unique_identifier,

View File

@ -36,6 +36,7 @@ from models import Account
from models.dataset import Dataset, DatasetCollectionBinding, Pipeline
from models.enums import CollectionBindingType, DatasetRuntimeMode
from models.workflow import Workflow, WorkflowType
from services.dsl_content import DSL_MAX_SIZE, dsl_content_size
from services.dsl_version import check_version_compatibility
from services.entities.dsl_entities import CheckDependenciesResult, ImportMode, ImportStatus
from services.entities.knowledge_entities.rag_pipeline_entities import (
@ -50,7 +51,6 @@ logger = logging.getLogger(__name__)
IMPORT_INFO_REDIS_KEY_PREFIX = "app_import_info:"
CHECK_DEPENDENCIES_REDIS_KEY_PREFIX = "app_check_dependencies:"
IMPORT_INFO_REDIS_EXPIRY = 10 * 60 # 10 minutes
DSL_MAX_SIZE = 10 * 1024 * 1024 # 10MB
CURRENT_DSL_VERSION = "0.1.0"
@ -127,15 +127,16 @@ class RagPipelineDslService:
yaml_url = yaml_url.replace("/blob/", "/")
response = remote_fetcher.make_request("GET", yaml_url.strip(), follow_redirects=True, timeout=(10, 10))
response.raise_for_status()
content = response.content.decode()
raw_content = response.content
if len(content) > DSL_MAX_SIZE:
if dsl_content_size(raw_content) > DSL_MAX_SIZE:
return RagPipelineImportInfo(
id=import_id,
status=ImportStatus.FAILED,
error="File size exceeds the limit of 10MB",
)
content = raw_content.decode("utf-8")
if not content:
return RagPipelineImportInfo(
id=import_id,
@ -156,6 +157,12 @@ class RagPipelineDslService:
error="yaml_content is required when import_mode is yaml-content",
)
content = yaml_content
if dsl_content_size(content) > DSL_MAX_SIZE:
return RagPipelineImportInfo(
id=import_id,
status=ImportStatus.FAILED,
error="File size exceeds the limit of 10MB",
)
# Process YAML content
try:

View File

@ -269,11 +269,13 @@ class RagPipelineTransformService:
installed_plugins_ids = [plugin.plugin_id for plugin in installed_plugins]
dependencies = pipeline_yaml.get("dependencies", [])
need_install_plugin_unique_identifiers = []
package_identifiers_to_install = []
for dependency in dependencies:
if dependency.get("type") == "marketplace":
plugin_unique_identifier = dependency.get("value", {}).get("plugin_unique_identifier")
plugin_id = plugin_unique_identifier.split(":")[0]
package_identifier = dependency.get("value", {}).get("plugin_unique_identifier")
if not package_identifier:
continue
plugin_id = package_identifier.split(":", 1)[0]
if plugin_id not in installed_plugins_ids:
if not dify_config.MARKETPLACE_ENABLED:
logger.warning(
@ -282,12 +284,12 @@ class RagPipelineTransformService:
plugin_id,
)
continue
plugin_unique_identifier = plugin_migration._fetch_plugin_unique_identifier(plugin_id) # type: ignore
if plugin_unique_identifier:
need_install_plugin_unique_identifiers.append(plugin_unique_identifier)
if need_install_plugin_unique_identifiers:
logger.debug("Installing missing pipeline plugins %s", need_install_plugin_unique_identifiers)
PluginService.install_from_marketplace_pkg(tenant_id, need_install_plugin_unique_identifiers)
latest_package_identifier = plugin_migration._fetch_latest_package_identifier(plugin_id) # type: ignore
if latest_package_identifier:
package_identifiers_to_install.append(latest_package_identifier)
if package_identifiers_to_install:
logger.debug("Installing missing pipeline plugins %s", package_identifiers_to_install)
PluginService.install_from_marketplace_pkg(tenant_id, package_identifiers_to_install)
def _transform_to_empty_pipeline(self, dataset: Dataset, session: Session):
pipeline = Pipeline(

View File

@ -3,12 +3,10 @@ import logging
import uuid
from collections.abc import Mapping
from datetime import UTC, datetime
from enum import StrEnum
from urllib.parse import urlparse
import yaml
from packaging import version
from pydantic import BaseModel, Field
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.orm import Session
@ -20,6 +18,9 @@ from graphon.model_runtime.utils.encoders import jsonable_encoder
from models import Account
from models.snippet import CustomizedSnippet, SnippetType
from models.workflow import Workflow
from services.dsl_content import DSL_MAX_SIZE, dsl_content_size
from services.dsl_version import check_version_compatibility
from services.entities.dsl_entities import CheckDependenciesResult, ImportMode, ImportStatus
from services.plugin.dependencies_analysis import DependenciesAnalysisService
from services.snippet_service import SNIPPET_FORBIDDEN_NODE_TYPES, SnippetService
@ -28,22 +29,9 @@ logger = logging.getLogger(__name__)
IMPORT_INFO_REDIS_KEY_PREFIX = "snippet_import_info:"
CHECK_DEPENDENCIES_REDIS_KEY_PREFIX = "snippet_check_dependencies:"
IMPORT_INFO_REDIS_EXPIRY = 10 * 60 # 10 minutes
DSL_MAX_SIZE = 10 * 1024 * 1024 # 10MB
CURRENT_DSL_VERSION = "0.1.0"
class ImportMode(StrEnum):
YAML_CONTENT = "yaml-content"
YAML_URL = "yaml-url"
class ImportStatus(StrEnum):
COMPLETED = "completed"
COMPLETED_WITH_WARNINGS = "completed-with-warnings"
PENDING = "pending"
FAILED = "failed"
class SnippetImportInfo(BaseModel):
id: str
status: ImportStatus
@ -53,32 +41,9 @@ class SnippetImportInfo(BaseModel):
error: str = ""
class CheckDependenciesResult(BaseModel):
leaked_dependencies: list[PluginDependency] = Field(default_factory=list)
def _check_version_compatibility(imported_version: str) -> ImportStatus:
"""Determine import status based on version comparison"""
try:
current_ver = version.parse(CURRENT_DSL_VERSION)
imported_ver = version.parse(imported_version)
except version.InvalidVersion:
return ImportStatus.FAILED
# If imported version is newer than current, always return PENDING
if imported_ver > current_ver:
return ImportStatus.PENDING
# If imported version is older than current's major, return PENDING
if imported_ver.major < current_ver.major:
return ImportStatus.PENDING
# If imported version is older than current's minor, return COMPLETED_WITH_WARNINGS
if imported_ver.minor < current_ver.minor:
return ImportStatus.COMPLETED_WITH_WARNINGS
# If imported version equals or is older than current's micro, return COMPLETED
return ImportStatus.COMPLETED
"""Determine import status based on version comparison."""
return check_version_compatibility(imported_version, CURRENT_DSL_VERSION)
class SnippetPendingData(BaseModel):
@ -145,13 +110,14 @@ class SnippetDslService:
status=ImportStatus.FAILED,
error=f"Failed to fetch YAML from URL: {response.status_code}",
)
content = response.text
if len(content) > DSL_MAX_SIZE:
raw_content = response.content
if dsl_content_size(raw_content) > DSL_MAX_SIZE:
return SnippetImportInfo(
id=import_id,
status=ImportStatus.FAILED,
error=f"YAML content size exceeds maximum limit of {DSL_MAX_SIZE} bytes",
)
content = raw_content.decode("utf-8")
except Exception as e:
logger.exception("Failed to fetch YAML from URL")
return SnippetImportInfo(
@ -167,7 +133,7 @@ class SnippetDslService:
error="yaml_content is required when import_mode is yaml-content",
)
content = yaml_content
if len(content) > DSL_MAX_SIZE:
if dsl_content_size(content) > DSL_MAX_SIZE:
return SnippetImportInfo(
id=import_id,
status=ImportStatus.FAILED,

View File

@ -8,7 +8,7 @@ from unittest.mock import MagicMock, patch
from uuid import uuid4
import pytest
from flask import Response
from flask import Flask, Response
from pydantic import ValidationError
import controllers.mcp.mcp as module
@ -37,12 +37,15 @@ class DummyServer:
self.app_id = app_id
self.tenant_id = tenant_id
self.id = server_id
self.description = "Test server"
self.parameters_dict = {}
class DummyApp:
def __init__(self, mode, workflow=None, app_model_config=None):
self.id = _APP_ID
self.tenant_id = _TENANT_ID
self.name = "test_app"
self.mode = mode
self.workflow = workflow
self.app_model_config = app_model_config
@ -494,3 +497,220 @@ class TestMCPAppApi:
with pytest.raises(module.MCPRequestError) as exc_info:
post_fn("server-1")
assert "Invalid user_input_form" in str(exc_info.value)
_UNSUPPORTED_VERSION = "1999-01-01"
def _initialize_payload(protocol_version: str = "2024-11-05") -> dict[str, object]:
return {
"jsonrpc": "2.0",
"method": "initialize",
"id": 1,
"params": {
"protocolVersion": protocol_version,
"capabilities": {},
"clientInfo": {"name": "test-client", "version": "1.0"},
},
}
def _tools_list_payload(request_id: int | None = 1) -> dict[str, object]:
payload: dict[str, object] = {"jsonrpc": "2.0", "method": "tools/list", "params": {}}
if request_id is not None:
payload["id"] = request_id
return payload
def _tools_call_payload() -> dict[str, object]:
return {
"jsonrpc": "2.0",
"method": "tools/call",
"id": 1,
"params": {"name": "test_app", "arguments": {"query": "test question"}},
}
class TestMCPProtocolVersionNegotiationApi:
"""MCP protocol version negotiation exercised through the HTTP controller layer.
Covers the MCP-Protocol-Version header contract (resolution, rejection, threading)
and the serialized JSON responses seen by modern (2025-06-18) vs legacy (2024-11-05)
clients, including the back-compat guarantee that legacy responses carry none of the
structured-output fields.
"""
def _make_api(self) -> module.MCPAppApi:
server = DummyServer(status=module.AppMCPServerStatus.ACTIVE)
app = DummyApp(mode=module.AppMode.CHAT, app_model_config=DummyConfig())
api = module.MCPAppApi()
api._get_mcp_server_and_app = MagicMock(return_value=(server, app))
api._retrieve_end_user = MagicMock(return_value=MagicMock())
return api
def _post(
self, flask_app: Flask, api: module.MCPAppApi, payload: dict[str, object], headers: dict[str, str] | None = None
) -> Response:
fake_payload(payload)
post_fn = unwrap(api.post)
with flask_app.test_request_context(headers=headers):
return post_fn("server-1")
@pytest.mark.parametrize("version", sorted(module.mcp_types.SERVER_SUPPORTED_PROTOCOL_VERSIONS))
def test_initialize_echoes_supported_body_version(self, flask_app_with_containers, version):
"""Initialize echoes every supported client-requested version back unchanged."""
api = self._make_api()
response = self._post(flask_app_with_containers, api, _initialize_payload(version))
body = response.get_json()
assert body["result"]["protocolVersion"] == version
def test_initialize_falls_back_for_unsupported_body_version(self, flask_app_with_containers):
"""An unsupported requested version falls back to the server latest."""
api = self._make_api()
response = self._post(flask_app_with_containers, api, _initialize_payload(_UNSUPPORTED_VERSION))
body = response.get_json()
assert body["result"]["protocolVersion"] == module.mcp_types.SERVER_LATEST_PROTOCOL_VERSION
def test_initialize_ignores_unsupported_header(self, flask_app_with_containers):
"""Initialize negotiates via the request body, so its header is never rejected."""
api = self._make_api()
response = self._post(
flask_app_with_containers,
api,
_initialize_payload("2024-11-05"),
headers={"MCP-Protocol-Version": _UNSUPPORTED_VERSION},
)
body = response.get_json()
assert "error" not in body
assert body["result"]["protocolVersion"] == "2024-11-05"
@pytest.mark.parametrize("request_id", [5, None])
def test_unsupported_header_returns_invalid_request_error(self, flask_app_with_containers, request_id):
"""An unsupported header gets a JSON-RPC error echoing the request id (missing id -> null)."""
api = self._make_api()
with patch.object(module, "handle_mcp_request", autospec=True) as mock_handle:
response = self._post(
flask_app_with_containers,
api,
_tools_list_payload(request_id=request_id),
headers={"MCP-Protocol-Version": _UNSUPPORTED_VERSION},
)
body = response.get_json()
assert response.status_code == 200
assert body["jsonrpc"] == "2.0"
assert body["id"] == request_id
assert body["error"]["code"] == module.mcp_types.INVALID_REQUEST
assert _UNSUPPORTED_VERSION in body["error"]["message"]
mock_handle.assert_not_called()
def test_notification_with_unsupported_header_is_accepted(self, flask_app_with_containers):
"""A notification is accepted (202, no body) even with an unsupported header."""
api = self._make_api()
response = self._post(
flask_app_with_containers,
api,
{"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}},
headers={"MCP-Protocol-Version": _UNSUPPORTED_VERSION},
)
assert response.status_code == 202
@pytest.mark.parametrize("version", sorted(module.mcp_types.SERVER_SUPPORTED_PROTOCOL_VERSIONS))
def test_supported_header_is_threaded_to_handler(self, flask_app_with_containers, version):
"""Every supported header value is passed through to handle_mcp_request."""
api = self._make_api()
with patch.object(module, "handle_mcp_request", return_value=DummyResult(), autospec=True) as mock_handle:
self._post(
flask_app_with_containers,
api,
_tools_list_payload(),
headers={"MCP-Protocol-Version": version},
)
assert mock_handle.call_args.args[-1] == version
def test_absent_header_defaults_to_back_compat_version(self, flask_app_with_containers):
"""An absent header resolves to the spec's default version (2025-03-26)."""
api = self._make_api()
with patch.object(module, "handle_mcp_request", return_value=DummyResult(), autospec=True) as mock_handle:
self._post(flask_app_with_containers, api, _tools_list_payload())
assert mock_handle.call_args.args[-1] == module.mcp_types.DEFAULT_NEGOTIATED_VERSION
def test_tools_list_json_advertises_structured_output_for_modern_client(self, flask_app_with_containers):
"""A 2025-06-18 client sees outputSchema and title in the serialized tool JSON."""
api = self._make_api()
response = self._post(
flask_app_with_containers,
api,
_tools_list_payload(),
headers={"MCP-Protocol-Version": "2025-06-18"},
)
tool = response.get_json()["result"]["tools"][0]
assert tool["outputSchema"] == {"type": "object"}
assert tool["title"] == "test_app"
def test_tools_list_json_unchanged_for_legacy_client(self, flask_app_with_containers):
"""A 2024-11-05 client sees exactly the pre-upgrade tool JSON keys."""
api = self._make_api()
response = self._post(
flask_app_with_containers,
api,
_tools_list_payload(),
headers={"MCP-Protocol-Version": "2024-11-05"},
)
tool = response.get_json()["result"]["tools"][0]
assert set(tool) == {"name", "description", "inputSchema"}
@patch("core.mcp.server.streamable_http.AppGenerateService")
def test_tools_call_json_includes_structured_content_for_modern_client(
self, mock_app_generate, flask_app_with_containers
):
"""A 2025-06-18 client receives structuredContent alongside the text content."""
api = self._make_api()
mock_app_generate.generate.return_value = {"answer": "test answer"}
response = self._post(
flask_app_with_containers,
api,
_tools_call_payload(),
headers={"MCP-Protocol-Version": "2025-06-18"},
)
result = response.get_json()["result"]
assert result["structuredContent"] == {"answer": "test answer"}
assert result["content"][0]["text"] == "test answer"
@patch("core.mcp.server.streamable_http.AppGenerateService")
def test_tools_call_json_omits_structured_content_for_legacy_client(
self, mock_app_generate, flask_app_with_containers
):
"""A 2024-11-05 client receives the pre-upgrade tools/call JSON without structuredContent."""
api = self._make_api()
mock_app_generate.generate.return_value = {"answer": "test answer"}
response = self._post(
flask_app_with_containers,
api,
_tools_call_payload(),
headers={"MCP-Protocol-Version": "2024-11-05"},
)
result = response.get_json()["result"]
assert "structuredContent" not in result
assert result["content"][0]["text"] == "test answer"

View File

@ -32,15 +32,6 @@ from tests.helpers.legacy_model_type_migration import (
)
@pytest.fixture
def sqlite_engine(tmp_path: Path) -> sa.Engine:
engine = sa.create_engine(f"sqlite:///{tmp_path / 'legacy_model_type_migration.sqlite'}")
try:
yield engine
finally:
engine.dispose()
@pytest.fixture
def dirty_fixture(sqlite_engine: sa.Engine):
return seed_legacy_model_type_dirty_data(sqlite_engine)

View File

@ -1,9 +1,12 @@
import os
from collections.abc import Iterator
from unittest.mock import MagicMock, patch
import pytest
from flask import Flask
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from sqlalchemy.orm import Session, sessionmaker
# Getting the absolute path of the current file's directory
ABS_PATH = os.path.dirname(os.path.abspath(__file__))
@ -34,6 +37,7 @@ os.environ.setdefault("STORAGE_TYPE", "opendal")
from core.db.session_factory import configure_session_factory, session_factory
from extensions import ext_redis
from models.base import TypeBase
def _patch_redis_clients_on_loaded_modules():
@ -113,6 +117,29 @@ def _unit_test_engine():
engine.dispose()
@pytest.fixture
def sqlite_engine() -> Iterator[Engine]:
"""Create an isolated in-memory SQLite engine for tests that need a disposable database."""
engine = create_engine("sqlite:///:memory:")
try:
yield engine
finally:
engine.dispose()
@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``."""
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
@pytest.fixture(autouse=True)
def _configure_session_factory(_unit_test_engine):
try:

View File

@ -85,15 +85,13 @@ def test_published_agent_app_parameters_requires_existing_active_agent(monkeypat
@pytest.mark.parametrize(
("active_config_snapshot_id", "active_config_is_published"),
"active_config_is_published",
[
(None, True),
("snapshot-1", False),
True,
False,
],
)
def test_published_agent_app_parameters_requires_published_agent(
monkeypatch, active_config_snapshot_id, active_config_is_published
):
def test_published_agent_app_parameters_requires_published_agent(monkeypatch, active_config_is_published):
app_model = SimpleNamespace(
tenant_id="tenant-1",
bound_agent_id="agent-1",
@ -101,7 +99,7 @@ def test_published_agent_app_parameters_requires_published_agent(
)
agent = SimpleNamespace(
id="agent-1",
active_config_snapshot_id=active_config_snapshot_id,
active_config_snapshot_id=None,
active_config_is_published=active_config_is_published,
)
monkeypatch.setattr(agent_app_parameters.db.session, "scalar", lambda _: agent)
@ -110,6 +108,27 @@ def test_published_agent_app_parameters_requires_published_agent(
get_published_agent_app_feature_dict_and_user_input_form(app_model)
def test_published_agent_app_parameters_allows_unpublished_draft_with_active_snapshot(monkeypatch):
app_model = SimpleNamespace(
tenant_id="tenant-1",
bound_agent_id="agent-1",
app_model_config=None,
)
agent = SimpleNamespace(
id="agent-1",
active_config_snapshot_id="snapshot-1",
active_config_is_published=False,
)
snapshot = SimpleNamespace(config_snapshot_dict={})
query_results = iter([agent, snapshot])
monkeypatch.setattr(agent_app_parameters.db.session, "scalar", lambda _: next(query_results))
features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form(app_model)
assert features_dict["file_upload"]["enabled"] is True
assert user_input_form == []
def test_published_agent_app_parameters_requires_published_snapshot(monkeypatch):
app_model = SimpleNamespace(
tenant_id="tenant-1",

View File

@ -13,8 +13,10 @@ from unittest.mock import ANY, MagicMock, patch
import pytest
from flask import Flask
from flask_restx import Api
from werkzeug.exceptions import Unauthorized
from controllers.console.auth.login import RefreshTokenApi
from services.errors.account import RefreshTokenAccountNotFoundError, RefreshTokenNotFoundError
class TestRefreshTokenApi:
@ -98,18 +100,19 @@ class TestRefreshTokenApi:
@patch("controllers.console.auth.login.extract_refresh_token", autospec=True)
@patch("controllers.console.auth.login.AccountService.refresh_token", autospec=True)
def test_refresh_fails_with_invalid_token(self, mock_refresh_token, mock_extract_token, app: Flask):
def test_refresh_returns_unauthorized_for_invalid_refresh_token(
self, mock_refresh_token, mock_extract_token, app: Flask
):
"""
Test token refresh failure with invalid refresh token.
Test token refresh maps invalid refresh tokens to unauthorized responses.
Verifies that:
- Exception is caught when token is invalid
- 401 status code is returned
- Error message is included in response
- Invalid refresh token validation failures return 401
- The failure response preserves the validation message
"""
# Arrange
mock_extract_token.return_value = "invalid_refresh_token"
mock_refresh_token.side_effect = Exception("Invalid refresh token")
mock_refresh_token.side_effect = RefreshTokenNotFoundError("Invalid refresh token")
# Act
with app.test_request_context("/refresh-token", method="POST"):
@ -119,22 +122,21 @@ class TestRefreshTokenApi:
# Assert
assert status_code == 401
assert response["result"] == "fail"
assert "Invalid refresh token" in response["message"]
assert response["message"] == "Invalid refresh token"
@patch("controllers.console.auth.login.extract_refresh_token", autospec=True)
@patch("controllers.console.auth.login.AccountService.refresh_token", autospec=True)
def test_refresh_fails_with_expired_token(self, mock_refresh_token, mock_extract_token, app: Flask):
def test_refresh_returns_unauthorized_for_invalid_account(self, mock_refresh_token, mock_extract_token, app: Flask):
"""
Test token refresh failure with expired refresh token.
Test token refresh maps missing accounts to unauthorized responses.
Verifies that:
- Expired tokens are rejected
- 401 status code is returned
- Appropriate error handling
- Invalid account validation failures return 401
- The failure response preserves the validation message
"""
# Arrange
mock_extract_token.return_value = "expired_refresh_token"
mock_refresh_token.side_effect = Exception("Refresh token expired")
mock_extract_token.return_value = "refresh_token_for_missing_account"
mock_refresh_token.side_effect = RefreshTokenAccountNotFoundError("Invalid account")
# Act
with app.test_request_context("/refresh-token", method="POST"):
@ -144,7 +146,71 @@ class TestRefreshTokenApi:
# Assert
assert status_code == 401
assert response["result"] == "fail"
assert "expired" in response["message"].lower()
assert response["message"] == "Invalid account"
@patch("controllers.console.auth.login.extract_refresh_token", autospec=True)
@patch("controllers.console.auth.login.AccountService.refresh_token", autospec=True)
def test_refresh_returns_unauthorized_for_banned_account(self, mock_refresh_token, mock_extract_token, app: Flask):
"""
Test token refresh maps banned accounts to unauthorized responses.
Verifies that:
- Authorization failures raised during account loading return 401
- The failure response preserves the authorization message
"""
# Arrange
mock_extract_token.return_value = "refresh_token_for_banned_account"
mock_refresh_token.side_effect = Unauthorized("Account is banned.")
# Act
with app.test_request_context("/refresh-token", method="POST"):
refresh_api = RefreshTokenApi()
response, status_code = refresh_api.post()
# Assert
assert status_code == 401
assert response["result"] == "fail"
assert response["message"] == "Account is banned."
@patch("controllers.console.auth.login.extract_refresh_token", autospec=True)
@patch("controllers.console.auth.login.AccountService.refresh_token", autospec=True)
def test_refresh_propagates_non_whitelisted_value_error(self, mock_refresh_token, mock_extract_token, app: Flask):
"""
Test token refresh preserves non-whitelisted ValueError failures.
Verifies that:
- Only known refresh-token validation errors are mapped to 401
- Unexpected ValueError instances continue to propagate
"""
# Arrange
mock_extract_token.return_value = "valid_refresh_token"
mock_refresh_token.side_effect = ValueError("unexpected parse failure")
# Act & Assert
with app.test_request_context("/refresh-token", method="POST"):
refresh_api = RefreshTokenApi()
with pytest.raises(ValueError, match="unexpected parse failure"):
refresh_api.post()
@patch("controllers.console.auth.login.extract_refresh_token", autospec=True)
@patch("controllers.console.auth.login.AccountService.refresh_token", autospec=True)
def test_refresh_propagates_unexpected_service_errors(self, mock_refresh_token, mock_extract_token, app: Flask):
"""
Test token refresh preserves unexpected service failures.
Verifies that:
- Operational errors are not misreported as authentication failures
- The original exception is preserved for higher-level error handling
"""
# Arrange
mock_extract_token.return_value = "valid_refresh_token"
mock_refresh_token.side_effect = RuntimeError("redis unavailable")
# Act & Assert
with app.test_request_context("/refresh-token", method="POST"):
refresh_api = RefreshTokenApi()
with pytest.raises(RuntimeError, match="redis unavailable"):
refresh_api.post()
@patch("controllers.console.auth.login.extract_refresh_token", autospec=True)
@patch("controllers.console.auth.login.AccountService.refresh_token", autospec=True)

View File

@ -97,12 +97,35 @@ class TestResolveAgent:
assert config_version_kind == "snapshot"
assert soul.model is not None
def test_unpublished_agent_raises_before_model_resolution(self, monkeypatch: pytest.MonkeyPatch):
def test_unpublished_draft_still_resolves_active_snapshot(self, monkeypatch: pytest.MonkeyPatch):
bound_agent = SimpleNamespace(
id="agent-1",
active_config_snapshot_id="snap-1",
active_config_is_published=False,
)
inner_agent = SimpleNamespace(id="agent-1")
snapshot = _snapshot()
_patch_session(monkeypatch, [bound_agent, inner_agent, snapshot])
app_model = SimpleNamespace(id="app-1", tenant_id="t1")
agent, config_id, config_version_kind, soul = AgentAppGenerator()._resolve_agent(
app_model,
invoke_from=InvokeFrom.WEB_APP,
draft_type=None,
user=SimpleNamespace(id="user-1"),
) # type: ignore[arg-type]
assert agent is bound_agent
assert config_id == snapshot.id
assert config_version_kind == "snapshot"
assert soul.prompt.system_prompt == "You are Iris."
def test_agent_without_active_snapshot_raises_before_model_resolution(self, monkeypatch: pytest.MonkeyPatch):
bound_agent = SimpleNamespace(
id="agent-1",
active_config_snapshot_id=None,
active_config_is_published=False,
)
_patch_session(monkeypatch, [bound_agent])
app_model = SimpleNamespace(id="app-1", tenant_id="t1")

View File

@ -1,6 +1,5 @@
import json
from base64 import b64decode
from collections.abc import Mapping
from typing import Any
import pytest
@ -44,7 +43,7 @@ def test_serialize_inputs_encodes_payload() -> None:
def test_transform_response_parses_json_result_and_converts_scientific_notation() -> None:
response = '<<RESULT>>{"value": "1e+3", "nested": {"x": "2E-2"}, "arr": ["3e+1"]}<<RESULT>>'
result: Mapping[str, Any] = _DummyTransformer.transform_response(response)
result: dict[str, Any] = _DummyTransformer.transform_response(response)
assert result == {"value": 1000.0, "nested": {"x": 0.02}, "arr": [30.0]}

View File

@ -46,6 +46,18 @@ class TestUploadDSL:
with pytest.raises(ValueError, match="claim_code"):
upload_dsl(b"app: demo")
@patch("core.helper.creators.httpx.post")
def test_raises_on_non_string_claim_code(self, mock_post):
mock_response = MagicMock(spec=httpx.Response)
mock_response.json.return_value = {"data": {"claim_code": 123}}
mock_response.raise_for_status = MagicMock()
mock_post.return_value = mock_response
from core.helper.creators import upload_dsl
with pytest.raises(ValueError, match="claim_code"):
upload_dsl(b"app: demo")
@patch("core.helper.creators.httpx.post")
def test_raises_on_http_error(self, mock_post):
mock_response = MagicMock(spec=httpx.Response)

View File

@ -1,6 +1,7 @@
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from pytest_mock import MockerFixture
from core.helper.marketplace import (
@ -53,6 +54,16 @@ def test_batch_fetch_plugin_by_ids_returns_plugins_from_response(mocker: MockerF
response.raise_for_status.assert_called_once()
def test_batch_fetch_plugin_by_ids_rejects_invalid_plugins_response(mocker: MockerFixture) -> None:
response = MagicMock()
response.json.return_value = {"data": {"plugins": ["p1"]}}
response.raise_for_status.return_value = None
mocker.patch("core.helper.marketplace.httpx.post", return_value=response)
with pytest.raises(ValueError, match="plugins list"):
batch_fetch_plugin_by_ids(["p1"])
def test_batch_fetch_plugin_manifests_returns_empty_for_empty_input(mocker: MockerFixture) -> None:
post_mock = mocker.patch("core.helper.marketplace.httpx.post")

View File

@ -10,11 +10,13 @@ from core.mcp.server.streamable_http import (
build_parameter_schema,
convert_input_form_to_parameters,
extract_answer_from_response,
extract_structured_output,
handle_call_tool,
handle_initialize,
handle_list_tools,
handle_mcp_request,
handle_ping,
negotiate_protocol_version,
prepare_tool_arguments,
process_mapping_response,
)
@ -64,6 +66,8 @@ class TestHandleMCPRequest:
# Setup initialize request
self.mock_request.root = Mock(spec=types.InitializeRequest)
self.mock_request.root.id = 123
self.mock_request.root.params = Mock()
self.mock_request.root.params.protocolVersion = "2025-06-18"
request_type = Mock(return_value=types.InitializeRequest)
with patch("core.mcp.server.streamable_http.type", request_type):
@ -91,6 +95,33 @@ class TestHandleMCPRequest:
assert result.jsonrpc == "2.0"
assert result.id == 123
def test_handle_list_tools_request_threads_protocol_version(self):
"""The negotiated version reaches handle_list_tools through the dispatcher."""
self.mock_request.root = Mock(spec=types.ListToolsRequest)
self.mock_request.root.id = 123
result = handle_mcp_request(
Mock(), self.app, self.mock_request, self.user_input_form, self.mcp_server, self.end_user, 123, "2025-06-18"
)
assert isinstance(result, types.JSONRPCResponse)
tool = result.result["tools"][0]
assert tool["outputSchema"] == {"type": "object"}
assert tool["title"] == "test_app"
def test_handle_list_tools_request_legacy_serialization_unchanged(self):
"""A 2024-11-05 tools/list response serializes without any 2025-06-18 fields."""
self.mock_request.root = Mock(spec=types.ListToolsRequest)
self.mock_request.root.id = 123
result = handle_mcp_request(
Mock(), self.app, self.mock_request, self.user_input_form, self.mcp_server, self.end_user, 123, "2024-11-05"
)
assert isinstance(result, types.JSONRPCResponse)
tool = result.result["tools"][0]
assert set(tool) == {"name", "description", "inputSchema"}
@patch("core.mcp.server.streamable_http.AppGenerateService")
def test_handle_call_tool_request(self, mock_app_generate):
"""Test handling call tool request"""
@ -119,6 +150,43 @@ class TestHandleMCPRequest:
# Verify AppGenerateService was called
mock_app_generate.generate.assert_called_once()
@patch("core.mcp.server.streamable_http.AppGenerateService")
def test_handle_call_tool_request_threads_protocol_version(self, mock_app_generate):
"""The negotiated version reaches handle_call_tool through the dispatcher."""
mock_call_request = Mock(spec=types.CallToolRequest)
mock_call_request.params = Mock()
mock_call_request.params.arguments = {"query": "test question"}
mock_call_request.id = 123
self.mock_request.root = mock_call_request
mock_app_generate.generate.return_value = {"answer": "test answer"}
result = handle_mcp_request(
Mock(), self.app, self.mock_request, self.user_input_form, self.mcp_server, self.end_user, 123, "2025-06-18"
)
assert isinstance(result, types.JSONRPCResponse)
assert result.result["structuredContent"] == {"answer": "test answer"}
@patch("core.mcp.server.streamable_http.AppGenerateService")
def test_handle_call_tool_request_legacy_serialization_unchanged(self, mock_app_generate):
"""A 2024-11-05 tools/call response serializes without structuredContent."""
mock_call_request = Mock(spec=types.CallToolRequest)
mock_call_request.params = Mock()
mock_call_request.params.arguments = {"query": "test question"}
mock_call_request.id = 123
self.mock_request.root = mock_call_request
mock_app_generate.generate.return_value = {"answer": "test answer"}
result = handle_mcp_request(
Mock(), self.app, self.mock_request, self.user_input_form, self.mcp_server, self.end_user, 123, "2024-11-05"
)
assert isinstance(result, types.JSONRPCResponse)
assert "structuredContent" not in result.result
assert result.result["content"][0]["text"] == "test answer"
def test_handle_unknown_request_type(self):
"""Test handling unknown request type"""
@ -183,18 +251,49 @@ class TestIndividualHandlers:
result = handle_ping()
assert isinstance(result, types.EmptyResult)
def test_handle_initialize(self):
"""Test initialize handler"""
description = "Test server"
def test_handle_initialize_echoes_supported_version(self):
"""A supported requested version is echoed back unchanged."""
with patch("core.mcp.server.streamable_http.dify_config") as mock_config:
mock_config.project.version = "1.0.0"
result = handle_initialize(description)
result = handle_initialize("Test server", "2024-11-05")
assert isinstance(result, types.InitializeResult)
assert result.protocolVersion == types.SERVER_LATEST_PROTOCOL_VERSION
assert result.protocolVersion == "2024-11-05"
assert result.instructions == "Test server"
def test_handle_initialize_echoes_intermediate_version(self):
"""The intermediate supported version (2025-03-26) is echoed back."""
with patch("core.mcp.server.streamable_http.dify_config") as mock_config:
mock_config.project.version = "1.0.0"
result = handle_initialize("Test server", "2025-03-26")
assert result.protocolVersion == "2025-03-26"
def test_handle_initialize_negotiates_latest_for_modern_client(self):
"""A 2025-06-18 client gets 2025-06-18 back."""
with patch("core.mcp.server.streamable_http.dify_config") as mock_config:
mock_config.project.version = "1.0.0"
result = handle_initialize("Test server", "2025-06-18")
assert result.protocolVersion == "2025-06-18"
def test_handle_initialize_falls_back_for_unknown_version(self):
"""An unsupported requested version falls back to the server latest."""
with patch("core.mcp.server.streamable_http.dify_config") as mock_config:
mock_config.project.version = "1.0.0"
result = handle_initialize("Test server", "1999-01-01")
assert result.protocolVersion == types.SERVER_LATEST_PROTOCOL_VERSION
assert result.protocolVersion == "2025-06-18"
def test_handle_initialize_non_string_version_falls_back(self):
"""A malformed (non-string) requested version falls back to the server latest."""
with patch("core.mcp.server.streamable_http.dify_config") as mock_config:
mock_config.project.version = "1.0.0"
result = handle_initialize("Test server", 20250618)
assert result.protocolVersion == types.SERVER_LATEST_PROTOCOL_VERSION
def test_handle_list_tools(self):
"""Test list tools handler"""
app_name = "test_app"
@ -210,6 +309,30 @@ class TestIndividualHandlers:
assert result.tools[0].name == "test_app"
assert result.tools[0].description == "Test server"
def test_handle_list_tools_adds_structured_output_for_modern_client(self):
"""Tool advertises outputSchema and title when negotiated >= 2025-06-18."""
result = handle_list_tools("test_app", AppMode.CHAT, [], "Test server", {}, "2025-06-18")
tool = result.tools[0]
assert tool.outputSchema == {"type": "object"}
assert tool.title == "test_app"
def test_handle_list_tools_omits_structured_output_for_legacy_client(self):
"""Tool stays unchanged (no outputSchema/title) for 2024-11-05 clients."""
result = handle_list_tools("test_app", AppMode.CHAT, [], "Test server", {}, "2024-11-05")
tool = result.tools[0]
assert tool.outputSchema is None
assert tool.title is None
def test_handle_list_tools_omits_structured_output_for_intermediate_client(self):
"""The 2025-03-26 negotiated version is below the structured-output threshold."""
result = handle_list_tools("test_app", AppMode.CHAT, [], "Test server", {}, "2025-03-26")
tool = result.tools[0]
assert tool.outputSchema is None
assert tool.title is None
@patch("core.mcp.server.streamable_http.AppGenerateService")
def test_handle_call_tool(self, mock_app_generate):
"""Test call tool handler"""
@ -239,6 +362,44 @@ class TestIndividualHandlers:
assert hasattr(text_content, "text")
assert text_content.text == "test answer"
@patch("core.mcp.server.streamable_http.AppGenerateService")
def test_handle_call_tool_structured_output_modern_client(self, mock_app_generate):
"""structuredContent is attached alongside TextContent for >= 2025-06-18."""
app = Mock(spec=App)
app.mode = AppMode.CHAT
mock_request = Mock()
mock_call_request = Mock(spec=types.CallToolRequest)
mock_call_request.params = Mock()
mock_call_request.params.arguments = {"query": "test question"}
mock_request.root = mock_call_request
mock_app_generate.generate.return_value = {"answer": "test answer"}
result = handle_call_tool(Mock(), app, mock_request, [], Mock(spec=EndUser), "2025-06-18")
assert result.structuredContent == {"answer": "test answer"}
assert result.content[0].text == "test answer"
@patch("core.mcp.server.streamable_http.AppGenerateService")
def test_handle_call_tool_no_structured_output_legacy_client(self, mock_app_generate):
"""structuredContent is omitted for 2024-11-05 clients."""
app = Mock(spec=App)
app.mode = AppMode.CHAT
mock_request = Mock()
mock_call_request = Mock(spec=types.CallToolRequest)
mock_call_request.params = Mock()
mock_call_request.params.arguments = {"query": "test question"}
mock_request.root = mock_call_request
mock_app_generate.generate.return_value = {"answer": "test answer"}
result = handle_call_tool(Mock(), app, mock_request, [], Mock(spec=EndUser), "2024-11-05")
assert result.structuredContent is None
assert result.content[0].text == "test answer"
def test_handle_call_tool_no_end_user(self):
"""Test call tool handler without end user"""
app = Mock(spec=App)
@ -375,6 +536,65 @@ class TestUtilityFunctions:
assert result == "thinking...more thinking"
def test_extract_structured_output_workflow(self):
"""Workflow mode exposes the raw outputs mapping as structured content."""
app = Mock(spec=App)
app.mode = AppMode.WORKFLOW
response = {"data": {"outputs": {"result": "test result"}}}
assert extract_structured_output(app, response, "ignored") == {"result": "test result"}
def test_extract_structured_output_chat(self):
"""Chat mode wraps the answer string under an 'answer' key."""
app = Mock(spec=App)
app.mode = AppMode.CHAT
assert extract_structured_output(app, {"answer": "hi"}, "hi") == {"answer": "hi"}
def test_extract_structured_output_workflow_missing_outputs(self):
"""Missing or malformed outputs fall back to None."""
app = Mock(spec=App)
app.mode = AppMode.WORKFLOW
assert extract_structured_output(app, {"data": {}}, "ignored") is None
def test_extract_structured_output_workflow_non_mapping_response(self):
"""A non-mapping workflow response yields no structured output."""
app = Mock(spec=App)
app.mode = AppMode.WORKFLOW
assert extract_structured_output(app, None, "ignored") is None
def test_extract_structured_output_workflow_non_mapping_data(self):
"""A non-mapping 'data' entry yields no structured output."""
app = Mock(spec=App)
app.mode = AppMode.WORKFLOW
assert extract_structured_output(app, {"data": "not a mapping"}, "ignored") is None
def test_extract_structured_output_workflow_non_mapping_outputs(self):
"""A non-mapping 'outputs' entry yields no structured output."""
app = Mock(spec=App)
app.mode = AppMode.WORKFLOW
assert extract_structured_output(app, {"data": {"outputs": ["not", "a", "mapping"]}}, "ignored") is None
@pytest.mark.parametrize("mode", [AppMode.ADVANCED_CHAT, AppMode.AGENT_CHAT, AppMode.COMPLETION])
def test_extract_structured_output_other_answer_modes(self, mode):
"""Every chat-style mode wraps the answer string under an 'answer' key."""
app = Mock(spec=App)
app.mode = mode
assert extract_structured_output(app, {"answer": "hi"}, "hi") == {"answer": "hi"}
def test_extract_structured_output_unknown_mode(self):
"""Modes outside the MCP surface produce no structured output."""
app = Mock(spec=App)
app.mode = AppMode.CHANNEL
assert extract_structured_output(app, {"answer": "hi"}, "hi") is None
def test_process_mapping_response_invalid_mode(self):
"""Test processing mapping response with invalid app mode"""
app = Mock(spec=App)
@ -578,3 +798,29 @@ class TestUtilityFunctions:
# Or validation should also raise SchemaError
with pytest.raises(jsonschema.exceptions.SchemaError):
jsonschema.validate(instance={"count": 1.23}, schema=bad_schema)
class TestNegotiateProtocolVersion:
"""Test the MCP-Protocol-Version header resolver."""
def test_initialize_ignores_header(self):
"""Initialize negotiates via the request body, so its header is ignored."""
assert negotiate_protocol_version("anything", True) == types.DEFAULT_NEGOTIATED_VERSION
def test_absent_header_defaults(self):
"""An absent header defaults to 2025-03-26 per the spec back-compat rule."""
assert negotiate_protocol_version(None, False) == types.DEFAULT_NEGOTIATED_VERSION
def test_empty_header_treated_as_absent(self):
"""An empty header value is treated as absent and defaults to 2025-03-26."""
assert negotiate_protocol_version("", False) == types.DEFAULT_NEGOTIATED_VERSION
def test_supported_header_passes_through(self):
"""All supported header values are used as the negotiated version."""
assert negotiate_protocol_version("2025-06-18", False) == "2025-06-18"
assert negotiate_protocol_version("2025-03-26", False) == "2025-03-26"
assert negotiate_protocol_version("2024-11-05", False) == "2024-11-05"
def test_unsupported_header_returns_none(self):
"""An explicit but unsupported header signals an error (None)."""
assert negotiate_protocol_version("1999-01-01", False) is None

View File

@ -4,6 +4,7 @@ import pytest
from pydantic import ValidationError
from core.mcp.types import (
DEFAULT_NEGOTIATED_VERSION,
INTERNAL_ERROR,
INVALID_PARAMS,
INVALID_REQUEST,
@ -11,6 +12,7 @@ from core.mcp.types import (
METHOD_NOT_FOUND,
PARSE_ERROR,
SERVER_LATEST_PROTOCOL_VERSION,
SERVER_SUPPORTED_PROTOCOL_VERSIONS,
Annotations,
CallToolRequest,
CallToolRequestParams,
@ -59,7 +61,9 @@ class TestConstants:
def test_protocol_versions(self):
"""Test protocol version constants."""
assert LATEST_PROTOCOL_VERSION == "2025-06-18"
assert SERVER_LATEST_PROTOCOL_VERSION == "2024-11-05"
assert SERVER_LATEST_PROTOCOL_VERSION == "2025-06-18"
assert DEFAULT_NEGOTIATED_VERSION == "2025-03-26"
assert sorted(SERVER_SUPPORTED_PROTOCOL_VERSIONS) == ["2024-11-05", "2025-03-26", "2025-06-18"]
def test_error_codes(self):
"""Test JSON-RPC error code constants."""

View File

@ -1,3 +1,4 @@
import json
from unittest.mock import MagicMock, patch
import pytest
@ -8,17 +9,17 @@ from services.plugin.plugin_migration import PluginMigration
MIGRATION_MODULE = "services.plugin.plugin_migration"
def test_fetch_plugin_unique_identifier_returns_none_when_disabled(mocker: MockerFixture) -> None:
def test_fetch_latest_package_identifier_returns_none_when_disabled(mocker: MockerFixture) -> None:
mocker.patch("services.plugin.plugin_migration.dify_config.MARKETPLACE_ENABLED", False)
batch_fetch = mocker.patch("services.plugin.plugin_migration.marketplace.batch_fetch_plugin_manifests")
result = PluginMigration._fetch_plugin_unique_identifier("langgenius/openai")
result = PluginMigration._fetch_latest_package_identifier("langgenius/openai")
assert result is None
batch_fetch.assert_not_called()
def test_fetch_plugin_unique_identifier_calls_marketplace_when_enabled(mocker: MockerFixture) -> None:
def test_fetch_latest_package_identifier_calls_marketplace_when_enabled(mocker: MockerFixture) -> None:
mocker.patch("services.plugin.plugin_migration.dify_config.MARKETPLACE_ENABLED", True)
manifest = mocker.MagicMock()
manifest.latest_package_identifier = "langgenius/openai:1.0.0@abc"
@ -27,7 +28,7 @@ def test_fetch_plugin_unique_identifier_calls_marketplace_when_enabled(mocker: M
return_value=[manifest],
)
result = PluginMigration._fetch_plugin_unique_identifier("langgenius/openai")
result = PluginMigration._fetch_latest_package_identifier("langgenius/openai")
assert result == "langgenius/openai:1.0.0@abc"
@ -75,7 +76,27 @@ class TestHandlePluginInstanceInstall:
mock_marketplace.download_plugin_pkg.assert_called_once()
invalidate_cache.assert_called_once_with("tenant1")
assert "success" in result or "failed" in result
assert result["success"] == ["langgenius/openai"]
assert result["failed"] == []
def test_reports_failed_plugin_ids_when_install_batch_raises(self) -> None:
with (
patch(f"{MIGRATION_MODULE}.dify_config") as mock_cfg,
patch(f"{MIGRATION_MODULE}.marketplace") as mock_marketplace,
patch(f"{MIGRATION_MODULE}.PluginInstaller") as mock_installer_cls,
):
mock_cfg.MARKETPLACE_ENABLED = True
mock_marketplace.download_plugin_pkg.return_value = b"pkg_data"
mock_installer = MagicMock()
mock_installer_cls.return_value = mock_installer
mock_installer.install_from_identifiers.side_effect = RuntimeError("install failed")
result = PluginMigration.handle_plugin_instance_install(
"tenant1", {"langgenius/openai": "langgenius/openai:1.0.0@abc"}
)
assert result["success"] == []
assert result["failed"] == ["langgenius/openai"]
def test_install_plugins_invalidates_cache_after_direct_tenant_install(self, tmp_path) -> None:
extracted_plugins = tmp_path / "plugins.jsonl"
@ -102,3 +123,30 @@ class TestHandlePluginInstanceInstall:
mock_installer.install_from_identifiers.assert_called_once()
invalidate_cache.assert_called_once_with("tenant1")
def test_install_plugins_reports_missing_plugin_ids(self, tmp_path) -> None:
extracted_plugins = tmp_path / "plugins.jsonl"
output_file = tmp_path / "output.json"
extracted_plugins.write_text('{"tenant_id":"tenant1","plugins":["langgenius/openai","langgenius/missing"]}\n')
with (
patch(
f"{MIGRATION_MODULE}.PluginMigration.extract_unique_plugins",
return_value={
"plugins": {"langgenius/openai": "langgenius/openai:1.0.0@abc"},
"plugin_not_exist": ["langgenius/missing"],
},
),
patch(f"{MIGRATION_MODULE}.PluginMigration.handle_plugin_instance_install", return_value={}),
patch(f"{MIGRATION_MODULE}.PluginInstaller") as mock_installer_cls,
patch(f"{MIGRATION_MODULE}.PluginService.invalidate_plugin_model_providers_cache"),
):
mock_installer = MagicMock()
mock_installer.list_plugins.return_value = []
mock_installer_cls.return_value = mock_installer
PluginMigration.install_plugins(str(extracted_plugins), str(output_file), workers=1)
assert json.loads(output_file.read_text())["not_installed"] == [
{"tenant_id": "tenant1", "plugin_not_exist": ["langgenius/missing"]}
]

View File

@ -643,6 +643,19 @@ def test_import_rag_pipeline_yaml_content_requires_mapping() -> None:
assert "content must be a mapping" in result.error
def test_import_rag_pipeline_rejects_oversized_yaml_content_by_bytes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("services.rag_pipeline.rag_pipeline_dsl_service.DSL_MAX_SIZE", 1)
service = RagPipelineDslService(session=Mock())
account = Mock(current_tenant_id="t1")
result = service.import_rag_pipeline(account=account, import_mode="yaml-content", yaml_content="é")
assert result.status == ImportStatus.FAILED
assert "10MB" in result.error
def test_confirm_import_returns_failed_when_pending_data_is_invalid_type(mocker: MockerFixture) -> None:
mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.redis_client.get", return_value=object())
service = RagPipelineDslService(session=Mock())
@ -901,6 +914,46 @@ def test_import_rag_pipeline_url_size_exceeds_limit(mocker: MockerFixture) -> No
assert "10MB" in result.error
def test_import_rag_pipeline_rejects_oversized_yaml_url_bytes_before_decode(
mocker: MockerFixture,
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr("services.rag_pipeline.rag_pipeline_dsl_service.DSL_MAX_SIZE", 1)
response = Mock()
response.raise_for_status.return_value = None
response.content = b"\xff\xff"
mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.remote_fetcher.make_request", return_value=response)
service = RagPipelineDslService(session=Mock())
account = Mock(current_tenant_id="t1")
result = service.import_rag_pipeline(
account=account,
import_mode="yaml-url",
yaml_url="https://example.com/pipeline.yaml",
)
assert result.status == ImportStatus.FAILED
assert "10MB" in result.error
def test_import_rag_pipeline_returns_decode_error_for_invalid_yaml_url_bytes(mocker: MockerFixture) -> None:
response = Mock()
response.raise_for_status.return_value = None
response.content = b"\xff"
mocker.patch("services.rag_pipeline.rag_pipeline_dsl_service.remote_fetcher.make_request", return_value=response)
service = RagPipelineDslService(session=Mock())
account = Mock(current_tenant_id="t1")
result = service.import_rag_pipeline(
account=account,
import_mode="yaml-url",
yaml_url="https://example.com/pipeline.yaml",
)
assert result.status == ImportStatus.FAILED
assert "utf-8" in result.error
def test_import_rag_pipeline_fails_when_rag_pipeline_data_missing() -> None:
service = RagPipelineDslService(session=Mock())
account = Mock(current_tenant_id="t1")

View File

@ -89,7 +89,7 @@ def test_deal_dependencies_installs_missing_marketplace_plugins(mocker: MockerFi
installer_cls.return_value.list_plugins.return_value = [SimpleNamespace(plugin_id="installed-plugin")]
migration_cls = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginMigration")
migration_cls.return_value._fetch_plugin_unique_identifier.return_value = "missing-plugin:1.0.0"
migration_cls.return_value._fetch_latest_package_identifier.return_value = "missing-plugin:1.0.0"
install_mock = mocker.patch(
"services.rag_pipeline.rag_pipeline_transform_service.PluginService.install_from_marketplace_pkg"
@ -518,7 +518,7 @@ def test_deal_dependencies_installs_when_enabled(mocker: MockerFixture) -> None:
installer = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginInstaller").return_value
installer.list_plugins.return_value = []
migration = mocker.patch("services.rag_pipeline.rag_pipeline_transform_service.PluginMigration").return_value
migration._fetch_plugin_unique_identifier.return_value = "langgenius/openai:1.0.0@abc"
migration._fetch_latest_package_identifier.return_value = "langgenius/openai:1.0.0@abc"
install_call = mocker.patch(
"services.rag_pipeline.rag_pipeline_transform_service.PluginService.install_from_marketplace_pkg"
)

View File

@ -1,16 +1,13 @@
import json
from collections.abc import Iterator
from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import Session
from configs import dify_config
from models.account import Account, AccountStatus, TenantAccountRole, TenantStatus
from models.base import TypeBase
from services.account_service import AccountService, RegisterService, TenantService
from services.errors.account import (
AccountAlreadyInTenantError,
@ -22,16 +19,6 @@ from services.errors.account import (
)
@pytest.fixture
def sqlite_session(request: pytest.FixtureRequest) -> Iterator[Session]:
models: tuple[type[TypeBase], ...] = request.param
engine = create_engine("sqlite:///:memory:")
tables = [model.metadata.tables[model.__tablename__] for model in models]
Account.metadata.create_all(engine, tables=tables)
with Session(engine, expire_on_commit=False) as session:
yield session
class TestAccountAssociatedDataFactory:
"""Factory class for creating test data and mock objects for account service tests."""

View File

@ -0,0 +1,53 @@
from types import SimpleNamespace
from unittest.mock import Mock
from services.app_dsl_service import AppDslService, ImportStatus
def test_import_app_rejects_oversized_yaml_content_by_bytes(monkeypatch) -> None:
monkeypatch.setattr("services.app_dsl_service.DSL_MAX_SIZE", 1)
service = AppDslService(session=SimpleNamespace())
result = service.import_app(
account=SimpleNamespace(current_tenant_id="tenant-1"),
import_mode="yaml-content",
yaml_content="é",
)
assert result.status == ImportStatus.FAILED
assert "10MB" in result.error
def test_import_app_rejects_oversized_yaml_url_bytes_before_decode(monkeypatch) -> None:
monkeypatch.setattr("services.app_dsl_service.DSL_MAX_SIZE", 1)
response = Mock()
response.raise_for_status.return_value = None
response.content = b"\xff\xff"
monkeypatch.setattr("services.app_dsl_service.remote_fetcher.make_request", Mock(return_value=response))
service = AppDslService(session=SimpleNamespace())
result = service.import_app(
account=SimpleNamespace(current_tenant_id="tenant-1"),
import_mode="yaml-url",
yaml_url="https://example.com/app.yaml",
)
assert result.status == ImportStatus.FAILED
assert "10MB" in result.error
def test_import_app_returns_decode_error_for_invalid_yaml_url_bytes(monkeypatch) -> None:
response = Mock()
response.raise_for_status.return_value = None
response.content = b"\xff"
monkeypatch.setattr("services.app_dsl_service.remote_fetcher.make_request", Mock(return_value=response))
service = AppDslService(session=SimpleNamespace())
result = service.import_app(
account=SimpleNamespace(current_tenant_id="tenant-1"),
import_mode="yaml-url",
yaml_url="https://example.com/app.yaml",
)
assert result.status == ImportStatus.FAILED
assert "utf-8" in result.error

View File

@ -95,7 +95,7 @@ def test_import_snippet_rejects_oversized_yaml_url_content(monkeypatch: pytest.M
monkeypatch.setattr("services.snippet_dsl_service.DSL_MAX_SIZE", 3)
monkeypatch.setattr(
"services.snippet_dsl_service.ssrf_proxy.get",
Mock(return_value=SimpleNamespace(status_code=200, text="too large")),
Mock(return_value=SimpleNamespace(status_code=200, content=b"too large")),
)
result = service.import_snippet(
@ -108,6 +108,43 @@ def test_import_snippet_rejects_oversized_yaml_url_content(monkeypatch: pytest.M
assert "YAML content size exceeds maximum limit" in result.error
def test_import_snippet_rejects_oversized_yaml_url_bytes_before_decode(monkeypatch: pytest.MonkeyPatch) -> None:
service = SnippetDslService(session=SimpleNamespace())
monkeypatch.setattr("services.snippet_dsl_service.DSL_MAX_SIZE", 1)
monkeypatch.setattr(
"services.snippet_dsl_service.ssrf_proxy.get",
Mock(return_value=SimpleNamespace(status_code=200, content=b"\xff\xff")),
)
result = service.import_snippet(
account=SimpleNamespace(current_tenant_id="tenant-1"),
import_mode=ImportMode.YAML_URL.value,
yaml_url="https://example.com/snippet.yaml",
)
assert result.status == ImportStatus.FAILED
assert "YAML content size exceeds maximum limit" in result.error
def test_import_snippet_returns_decode_error_for_invalid_yaml_url_bytes(
monkeypatch: pytest.MonkeyPatch,
) -> None:
service = SnippetDslService(session=SimpleNamespace())
monkeypatch.setattr(
"services.snippet_dsl_service.ssrf_proxy.get",
Mock(return_value=SimpleNamespace(status_code=200, content=b"\xff")),
)
result = service.import_snippet(
account=SimpleNamespace(current_tenant_id="tenant-1"),
import_mode=ImportMode.YAML_URL.value,
yaml_url="https://example.com/snippet.yaml",
)
assert result.status == ImportStatus.FAILED
assert "utf-8" in result.error
def test_import_snippet_returns_failed_when_yaml_url_fetch_raises(monkeypatch: pytest.MonkeyPatch) -> None:
service = SnippetDslService(session=SimpleNamespace())
monkeypatch.setattr(
@ -127,12 +164,12 @@ def test_import_snippet_returns_failed_when_yaml_url_fetch_raises(monkeypatch: p
def test_import_snippet_rejects_oversized_yaml_content(monkeypatch: pytest.MonkeyPatch) -> None:
service = SnippetDslService(session=SimpleNamespace())
monkeypatch.setattr("services.snippet_dsl_service.DSL_MAX_SIZE", 3)
monkeypatch.setattr("services.snippet_dsl_service.DSL_MAX_SIZE", 1)
result = service.import_snippet(
account=SimpleNamespace(current_tenant_id="tenant-1"),
import_mode=ImportMode.YAML_CONTENT.value,
yaml_content="too large",
yaml_content="é",
)
assert result.status == ImportStatus.FAILED

View File

@ -153,7 +153,7 @@ ENABLE_WEBSITE_WATERCRAWL=true
NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
# Enable preview features still in development (currently the /create and
# /refine slash commands in the "Go to Anything" command palette).
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=false
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=true
ENABLE_AGENT_V2=false
EXPERIMENTAL_ENABLE_VINEXT=false

View File

@ -24,7 +24,7 @@ ENABLE_WEBSITE_JINAREADER=true
ENABLE_WEBSITE_FIRECRAWL=true
ENABLE_WEBSITE_WATERCRAWL=true
NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=false
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=true
ENABLE_AGENT_V2=false
NEXT_PUBLIC_COOKIE_DOMAIN=
NEXT_PUBLIC_BATCH_CONCURRENCY=5

View File

@ -1,3 +1,4 @@
import type { Page } from '@playwright/test'
import type { DifyWorld } from '../../support/world'
import { Given, Then, When } from '@cucumber/cucumber'
import { expect } from '@playwright/test'
@ -12,6 +13,9 @@ import {
const WEB_APP_RUNTIME_RESPONSE_STEP_TIMEOUT_MS = 180_000
const getWebAppMessageInput = (webAppPage: Page) =>
webAppPage.getByPlaceholder(/^Talk to /).last()
Then('I should see the Agent v2 Web app access URL', async function (this: DifyWorld) {
const webAppCard = getWebAppCard(this)
@ -71,7 +75,7 @@ When('I send an E2E message in the Agent v2 Web app', async function (this: Dify
if (!webAppPage)
throw new Error('No Agent v2 Web app page was opened.')
const messageInput = webAppPage.getByRole('textbox').last()
const messageInput = getWebAppMessageInput(webAppPage)
await expect(messageInput).toBeEditable({ timeout: 30_000 })
await messageInput.fill('Please reply with the test success marker.')
await messageInput.press('Enter')
@ -84,7 +88,7 @@ Then('the Agent v2 Web app should open in a new tab', async function (this: Dify
throw new Error('No Agent v2 Web app page was opened.')
await expect(webAppPage).toHaveURL(webAppURL)
await expect(webAppPage.getByRole('textbox').last()).toBeEditable({ timeout: 30_000 })
await expect(getWebAppMessageInput(webAppPage)).toBeEditable({ timeout: 30_000 })
await webAppPage.close()
this.agentBuilder.accessPoint.webAppPage = undefined
this.agentBuilder.accessPoint.webAppURL = undefined

View File

@ -254,9 +254,8 @@ Then('I should see the Agent v2 Build mode confirmation state', async function (
const page = this.getPage()
await expect(page.getByText('Build mode', { exact: true })).toBeVisible()
await expect(
page.getByText('You\'re in build mode. Shape this setup through the chat on the right, then Apply.'),
).toBeVisible()
await expect(page.getByText('Configure can only be updated by the agent in this mode.')).toBeVisible()
await expect(page.getByText('Shape this setup through the chat on the right, then Apply.')).toBeVisible()
})
Then('Agent v2 Build chat should be blocked until a model is configured', async function (this: DifyWorld) {

View File

@ -1,4 +1,4 @@
import type { Browser } from '@playwright/test'
import type { Browser, Page } from '@playwright/test'
import type { Buffer } from 'node:buffer'
import type { DifyWorld } from './world'
import { mkdir, writeFile } from 'node:fs/promises'
@ -34,18 +34,50 @@ const sanitizeForPath = (value: string) =>
const writeArtifact = async (
scenarioName: string,
label: string,
extension: 'html' | 'png',
contents: Buffer | string,
) => {
const artifactPath = path.join(
artifactsDir,
`${Date.now()}-${sanitizeForPath(scenarioName || 'scenario')}.${extension}`,
`${Date.now()}-${sanitizeForPath(scenarioName || 'scenario')}-${sanitizeForPath(label)}.${extension}`,
)
await writeFile(artifactPath, contents)
return artifactPath
}
const uniqueDiagnosticPages = (pages: { label: string, page: Page | undefined }[]) => {
const seen = new Set<Page>()
return pages.filter(({ page }) => {
if (!page || page.isClosed() || seen.has(page))
return false
seen.add(page)
return true
}) as { label: string, page: Page }[]
}
const captureDiagnosticPage = async (
world: DifyWorld,
scenarioName: string,
label: string,
page: Page,
) => {
const screenshot = await page.screenshot({
fullPage: true,
})
const screenshotPath = await writeArtifact(scenarioName, label, 'png', screenshot)
world.attach(screenshot, 'image/png')
const html = await page.content()
const htmlPath = await writeArtifact(scenarioName, label, 'html', html)
world.attach(html, 'text/html')
return [screenshotPath, htmlPath]
}
const recordCleanup = async (
errors: string[],
label: string,
@ -91,16 +123,26 @@ After(async function (this: DifyWorld, { pickle, result }) {
const elapsedMs = this.scenarioStartedAt ? Date.now() - this.scenarioStartedAt : undefined
const status = result?.status || Status.UNKNOWN
if (diagnosticArtifactStatuses.has(status) && this.page) {
const screenshot = await this.page.screenshot({
fullPage: true,
})
const screenshotPath = await writeArtifact(pickle.name, 'png', screenshot)
this.attach(screenshot, 'image/png')
if (diagnosticArtifactStatuses.has(status)) {
const artifactPaths: string[] = []
const artifactErrors: string[] = []
const diagnosticPages = uniqueDiagnosticPages([
{ label: 'main-page', page: this.page },
{ label: 'agent-v2-web-app', page: this.agentBuilder.accessPoint.webAppPage },
{ label: 'agent-v2-api-reference', page: this.agentBuilder.accessPoint.apiReferencePage },
{ label: 'agent-v2-workflow-reference', page: this.agentBuilder.accessPoint.workflowReferencePage },
{ label: 'agent-v2-concurrent-configure', page: this.agentBuilder.configure.concurrentPage },
{ label: 'agent-v2-workflow-console', page: this.agentBuilder.workflow.agentConsolePage },
])
const html = await this.page.content()
const htmlPath = await writeArtifact(pickle.name, 'html', html)
this.attach(html, 'text/html')
for (const { label, page } of diagnosticPages) {
try {
artifactPaths.push(...await captureDiagnosticPage(this, pickle.name, label, page))
}
catch (error) {
artifactErrors.push(`${label}: ${error instanceof Error ? error.message : String(error)}`)
}
}
if (this.consoleErrors.length > 0)
this.attach(`Console Errors:\n${this.consoleErrors.join('\n')}`, 'text/plain')
@ -108,7 +150,11 @@ After(async function (this: DifyWorld, { pickle, result }) {
if (this.pageErrors.length > 0)
this.attach(`Page Errors:\n${this.pageErrors.join('\n')}`, 'text/plain')
this.attach(`Artifacts:\n${[screenshotPath, htmlPath].join('\n')}`, 'text/plain')
if (artifactErrors.length > 0)
this.attach(`Artifact Errors:\n${artifactErrors.join('\n')}`, 'text/plain')
if (artifactPaths.length > 0)
this.attach(`Artifacts:\n${artifactPaths.join('\n')}`, 'text/plain')
}
console.warn(

View File

@ -7031,11 +7031,6 @@
"count": 1
}
},
"web/service/access-control/__tests__/use-permission-keys.spec.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/access-control/__tests__/use-workspace-access-rules.spec.tsx": {
"no-restricted-imports": {
"count": 1
@ -7056,11 +7051,6 @@
"count": 1
}
},
"web/service/access-control/use-permission-keys.ts": {
"no-restricted-imports": {
"count": 1
}
},
"web/service/access-control/use-workspace-access-rules.ts": {
"no-restricted-imports": {
"count": 1
@ -7228,17 +7218,6 @@
"count": 1
}
},
"web/service/use-common.ts": {
"no-restricted-imports": {
"count": 1
},
"ts/no-empty-object-type": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/service/use-datasource.ts": {
"no-restricted-imports": {
"count": 1

View File

@ -88,7 +88,7 @@ NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=false
# Enable preview features still in development (currently the /create and
# /refine slash commands in the "Go to Anything" command palette)
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=false
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=true
# Enable Agent v2 frontend entry points.
NEXT_PUBLIC_ENABLE_AGENT_V2=false

View File

@ -1,8 +1,9 @@
import type { ReactNode } from 'react'
import * as React from 'react'
import AmplitudeProvider from '@/app/components/base/amplitude'
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import Zendesk from '@/app/components/base/zendesk'
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
import MaintenanceNotice from '@/app/components/header/maintenance-notice'
import MainNavLayout from '@/app/components/main-nav/layout'
import { NextRouteStateBridge } from '@/app/components/next-route-state'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
@ -17,32 +18,35 @@ export default async function Layout({
children,
detailSidebar,
}: {
children: ReactNode
detailSidebar: ReactNode
children: React.ReactNode
detailSidebar: React.ReactNode
}) {
return (
<>
<React.Fragment>
<GoogleAnalyticsScripts />
<AmplitudeProvider />
<OAuthRegistrationAnalytics />
<EducationVerifyActionRecorder />
<CommonLayoutHydrationBoundary>
<NextRouteStateBridge>
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<MainNavLayout detailSidebar={detailSidebar}>
{children}
</MainNavLayout>
<CommonLayoutGlobalMounts />
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
<div className="flex h-full flex-col overflow-hidden">
<MaintenanceNotice />
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<MainNavLayout detailSidebar={detailSidebar}>
{children}
</MainNavLayout>
<CommonLayoutGlobalMounts />
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
</div>
</NextRouteStateBridge>
</CommonLayoutHydrationBoundary>
<Zendesk />
</>
</React.Fragment>
)
}

View File

@ -1,10 +1,10 @@
import type { ReactNode } from 'react'
import * as React from 'react'
import { CommonLayoutHydrationBoundary } from '@/app/(commonLayout)/hydration-boundary'
import AmplitudeProvider from '@/app/components/base/amplitude'
import { GoogleAnalyticsScripts } from '@/app/components/base/ga'
import { EducationVerifyActionRecorder } from '@/app/components/education-verify-action-recorder'
import HeaderWrapper from '@/app/components/header/header-wrapper'
import MaintenanceNotice from '@/app/components/header/maintenance-notice'
import { OAuthRegistrationAnalytics } from '@/app/components/oauth-registration-analytics'
import { AppContextProvider } from '@/context/app-context-provider'
import { EventEmitterContextProvider } from '@/context/event-emitter-provider'
@ -12,30 +12,32 @@ import { ModalContextProvider } from '@/context/modal-context-provider'
import { ProviderContextProvider } from '@/context/provider-context-provider'
import Header from './header'
const Layout = async ({ children }: { children: ReactNode }) => {
export default async function Layout({ children }: { children: React.ReactNode }) {
return (
<>
<React.Fragment>
<GoogleAnalyticsScripts />
<AmplitudeProvider />
<OAuthRegistrationAnalytics />
<EducationVerifyActionRecorder />
<CommonLayoutHydrationBoundary>
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<HeaderWrapper>
<Header />
</HeaderWrapper>
<div className="relative flex h-0 shrink-0 grow flex-col overflow-y-auto bg-components-panel-bg">
{children}
</div>
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
<div className="flex h-full flex-col overflow-hidden bg-background-body">
<MaintenanceNotice />
<AppContextProvider>
<EventEmitterContextProvider>
<ProviderContextProvider>
<ModalContextProvider>
<HeaderWrapper>
<Header />
</HeaderWrapper>
<div className="relative flex h-0 min-h-0 shrink-0 grow flex-col overflow-y-auto bg-components-panel-bg">
{children}
</div>
</ModalContextProvider>
</ProviderContextProvider>
</EventEmitterContextProvider>
</AppContextProvider>
</div>
</CommonLayoutHydrationBoundary>
</>
</React.Fragment>
)
}
export default Layout

View File

@ -4,14 +4,6 @@ import { createStore, Provider as JotaiProvider } from 'jotai'
import { useGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
import DatasetDetailTop from '../dataset-detail-top'
const mockBack = vi.fn()
vi.mock('@/next/navigation', () => ({
useRouter: () => ({
back: mockBack,
}),
}))
vi.mock('../toggle-button', () => ({
default: ({ expand, handleToggle, icon }: { expand: boolean, handleToggle: () => void, icon?: ReactNode }) => (
<button type="button" data-testid="toggle-button" data-expand={expand} data-has-icon={Boolean(icon)} onClick={handleToggle}>
@ -41,14 +33,15 @@ describe('DatasetDetailTop', () => {
vi.clearAllMocks()
})
it('links the home icon to home and labels the breadcrumb as datasets', () => {
it('links the combined home control to home and labels the breadcrumb as datasets', () => {
renderWithGotoAnythingStore(<DatasetDetailTop />)
expect(screen.getByRole('link', { name: 'common.mainNav.home' })).toHaveAttribute('href', '/')
expect(screen.getByRole('link', { name: 'common.menus.datasets' })).toHaveAttribute('href', '/datasets')
expect(screen.queryByRole('button', { name: 'common.operation.back' })).not.toBeInTheDocument()
})
it('keeps the back button and quick search actions', () => {
it('keeps the quick search action', () => {
renderWithGotoAnythingStore(
<>
<DatasetDetailTop />
@ -57,10 +50,8 @@ describe('DatasetDetailTop', () => {
)
expect(screen.getByTestId('goto-anything-open')).toHaveTextContent('false')
fireEvent.click(screen.getByRole('button', { name: 'common.operation.back' }))
fireEvent.click(screen.getByRole('button', { name: 'app.gotoAnything.searchTitle' }))
expect(mockBack).toHaveBeenCalledTimes(1)
expect(screen.getByTestId('goto-anything-open')).toHaveTextContent('true')
})

View File

@ -7,7 +7,6 @@ import { useTranslation } from 'react-i18next'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
import Link from '@/next/link'
import { useRouter } from '@/next/navigation'
import ToggleButton from './toggle-button'
type DatasetDetailTopProps = {
@ -22,7 +21,6 @@ const DatasetDetailTop = ({
onToggle,
}: DatasetDetailTopProps) => {
const { t } = useTranslation()
const router = useRouter()
const setGotoAnythingOpen = useSetGotoAnythingOpen()
if (!expand) {
@ -43,23 +41,14 @@ const DatasetDetailTop = ({
return (
<div className="flex items-center py-2 pr-2 pl-1">
<div className="flex min-w-0 flex-1 items-center gap-px">
<div className="flex shrink-0 items-center rounded-lg py-2 pr-1.5 pl-0.5 transition-colors hover:bg-background-default-hover">
<button
type="button"
aria-label={t('operation.back', { ns: 'common' })}
className="flex size-4 items-center justify-center text-text-tertiary hover:text-text-secondary"
onClick={() => router.back()}
>
<span aria-hidden className="i-ri-arrow-left-s-line size-4" />
</button>
<Link
href="/"
aria-label={t('mainNav.home', { ns: 'common' })}
className="flex size-4 items-center justify-center text-text-tertiary hover:text-text-secondary"
>
<span aria-hidden className="i-custom-vender-main-nav-app-home size-4" />
</Link>
</div>
<Link
href="/"
aria-label={t('mainNav.home', { ns: 'common' })}
className="flex shrink-0 items-center rounded-lg py-2 pr-1.5 pl-0.5 text-text-tertiary transition-colors hover:bg-background-default-hover hover:text-text-secondary"
>
<span aria-hidden className="i-ri-arrow-left-s-line size-4" />
<span aria-hidden className="i-custom-vender-main-nav-app-home size-4" />
</Link>
{expand && (
<>
<span className="shrink-0 system-md-regular text-text-quaternary">

View File

@ -4,10 +4,18 @@ import { useLanguage } from '@/app/components/header/account-setting/model-provi
import { NOTICE_I18N } from '@/i18n-config/language'
import MaintenanceNotice from '../maintenance-notice'
const mockEnv = vi.hoisted(() => ({
NEXT_PUBLIC_MAINTENANCE_NOTICE: 'true',
}))
vi.mock('@/app/components/base/icons/src/vender/line/general', () => ({
X: (props: React.SVGProps<SVGSVGElement>) => <svg {...props} />,
}))
vi.mock('@/env', () => ({
env: mockEnv,
}))
vi.mock(
'@/app/components/header/account-setting/model-provider-page/hooks',
() => ({
@ -44,6 +52,7 @@ describe('MaintenanceNotice', () => {
beforeEach(() => {
vi.clearAllMocks()
localStorage.clear()
mockEnv.NEXT_PUBLIC_MAINTENANCE_NOTICE = 'true'
vi.mocked(useLanguage).mockReturnValue('en_US')
setNoticeHref('#')
})
@ -71,6 +80,14 @@ describe('MaintenanceNotice', () => {
const { container } = render(<MaintenanceNotice />)
expect(container.firstChild).toBeNull()
})
it('should not render when the notice env flag is disabled', () => {
mockEnv.NEXT_PUBLIC_MAINTENANCE_NOTICE = ''
const { container } = render(<MaintenanceNotice />)
expect(container.firstChild).toBeNull()
})
})
describe('User Interactions', () => {

View File

@ -1,11 +1,21 @@
'use client'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { X } from '@/app/components/base/icons/src/vender/line/general'
import { useLanguage } from '@/app/components/header/account-setting/model-provider-page/hooks'
import { env } from '@/env'
import { NOTICE_I18N } from '@/i18n-config/language'
import { useHideMaintenanceNotice } from './storage'
const MaintenanceNotice = () => {
function MaintenanceNotice() {
if (!env.NEXT_PUBLIC_MAINTENANCE_NOTICE)
return null
return <MaintenanceNoticeContent />
}
function MaintenanceNoticeContent() {
const { t } = useTranslation()
const locale = useLanguage()

View File

@ -1,8 +1,18 @@
import { render, screen } from '@testing-library/react'
import { useAppContext, useSelector } from '../app-context'
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { Provider as JotaiProvider } from 'jotai'
import { queryClientAtom } from 'jotai-tanstack-query'
import { useHydrateAtoms } from 'jotai/react/utils'
import { Suspense } from 'react'
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
import { ZENDESK_FIELD_IDS } from '@/config'
import { initialWorkspaceInfo, useAppContext, useSelector } from '../app-context'
import { AppContextProvider } from '../app-context-provider'
const mockInvalidateQueries = vi.hoisted(() => vi.fn())
const mockGetRequest = vi.hoisted(() => vi.fn())
const mockPermissionKeysState = vi.hoisted(() => ({
isPending: false,
permissionKeys: ['app.create_and_management'],
@ -19,55 +29,84 @@ const mockCurrentWorkspaceResponse = vi.hoisted(() => ({
next_credit_reset_date: 1706745600,
custom_config: {},
}))
vi.mock('@tanstack/react-query', () => ({
useQueryClient: () => ({
invalidateQueries: mockInvalidateQueries,
}),
useSuspenseQuery: (options: { queryKey?: readonly unknown[] }) => {
if (options.queryKey?.[0] === 'system-features') {
return {
data: {
branding: {
enabled: false,
},
},
}
const mockCurrentWorkspaceQueryState = vi.hoisted(() => ({
data: mockCurrentWorkspaceResponse as typeof mockCurrentWorkspaceResponse | undefined,
isPending: false,
}))
const mockUserProfileResponseState = vi.hoisted(() => ({
data: {
profile: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
avatar: '',
avatar_url: '',
is_password_set: true,
},
meta: {
currentVersion: '1.0.0',
currentEnv: 'cloud',
},
} as {
profile?: {
id: string
name: string
email: string
avatar: string
avatar_url: string
is_password_set: boolean
}
return {
data: {
profile: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
avatar: '',
avatar_url: '',
is_password_set: true,
},
meta: {
currentVersion: '1.0.0',
currentEnv: 'cloud',
},
},
meta: {
currentVersion: string | null
currentEnv: string | null
}
},
useQuery: (options: { select?: (workspace: typeof mockCurrentWorkspaceResponse) => unknown }) => ({
data: options.select ? options.select(mockCurrentWorkspaceResponse) : mockCurrentWorkspaceResponse,
isFetching: false,
isPending: false,
}),
}))
const mockSystemFeaturesState = vi.hoisted(() => ({
data: {
branding: {
enabled: false,
},
},
}))
const mockLangGeniusVersionState = vi.hoisted(() => ({
data: {
version: '1.0.1',
release_date: '',
release_notes: '',
can_auto_update: false,
} as {
version: string
release_date: string
release_notes: string
can_auto_update: boolean
} | undefined,
}))
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/config')>()
return {
...actual,
ZENDESK_FIELD_IDS: {
ENVIRONMENT: 'environment-field',
VERSION: 'version-field',
EMAIL: 'email-field',
WORKSPACE_ID: 'workspace-id-field',
},
}
})
vi.mock('@/features/system-features/client', () => ({
systemFeaturesQueryOptions: () => ({
queryKey: ['system-features'],
queryFn: async () => mockSystemFeaturesState.data,
}),
}))
vi.mock('@/features/account-profile/client', () => ({
userProfileQueryOptions: () => ({
queryKey: ['user-profile'],
queryFn: async () => mockUserProfileResponseState.data,
}),
}))
@ -77,8 +116,16 @@ vi.mock('@/service/client', () => ({
current: {
post: {
key: () => ['current-workspace'],
queryOptions: (options: Record<string, unknown>) => ({
queryOptions: (options: {
select?: (workspace?: typeof mockCurrentWorkspaceResponse) => unknown
}) => ({
queryKey: ['current-workspace'],
queryFn: async () => {
if (mockCurrentWorkspaceQueryState.isPending)
return new Promise(() => {})
return mockCurrentWorkspaceQueryState.data
},
...options,
}),
},
@ -87,34 +134,9 @@ vi.mock('@/service/client', () => ({
},
}))
vi.mock('@/service/access-control/use-permission-keys', () => ({
useWorkspacePermissionKeys: () => ({
data: {
workspace: {
permission_keys: mockPermissionKeysState.permissionKeys,
},
app: {
default_permission_keys: [],
overrides: [],
},
dataset: {
default_permission_keys: [],
overrides: [],
},
},
isPending: mockPermissionKeysState.isPending,
}),
}))
vi.mock('@/service/use-common', () => ({
useLangGeniusVersion: () => ({
data: {
version: '1.0.1',
release_date: '',
release_notes: '',
can_auto_update: false,
},
}),
vi.mock('@/service/base', () => ({
get: mockGetRequest,
post: vi.fn(),
}))
vi.mock('@/app/components/base/amplitude', () => ({
@ -145,35 +167,303 @@ function AppContextProbe() {
{selectedWorkspacePermissionKeys.join(',')}
</span>
<span>
loading:
permission loading:
{String(context.isLoadingWorkspacePermissionKeys)}
</span>
<span>
workspace loading:
{String(context.isLoadingCurrentWorkspace)}
</span>
<span>
workspace validating:
{String(context.isValidatingCurrentWorkspace)}
</span>
<span>
user:
{context.userProfile.email}
</span>
<span>
workspace:
{context.currentWorkspace.name}
</span>
<span>
role:
{context.currentWorkspace.role}
</span>
<span>
manager:
{String(context.isCurrentWorkspaceManager)}
</span>
<span>
owner:
{String(context.isCurrentWorkspaceOwner)}
</span>
<span>
editor:
{String(context.isCurrentWorkspaceEditor)}
</span>
<span>
dataset operator:
{String(context.isCurrentWorkspaceDatasetOperator)}
</span>
<span>
version:
{context.langGeniusVersionInfo.current_version}
/
{context.langGeniusVersionInfo.latest_version}
/
{context.langGeniusVersionInfo.current_env}
</span>
<button type="button" onClick={context.mutateUserProfile}>refresh user</button>
<button type="button" onClick={context.mutateCurrentWorkspace}>refresh workspace</button>
</>
)
}
function TestQueryClientHydrator({
children,
queryClient,
}: {
children: ReactNode
queryClient: QueryClient
}) {
useHydrateAtoms(new Map([[queryClientAtom, queryClient]]))
return children
}
function createTestQueryClient() {
return new QueryClient({
defaultOptions: {
queries: {
retry: false,
staleTime: 0,
},
},
})
}
function renderProvider() {
const queryClient = createTestQueryClient()
const view = render(
<JotaiProvider>
<QueryClientProvider client={queryClient}>
<TestQueryClientHydrator queryClient={queryClient}>
<Suspense fallback={<span>loading</span>}>
<AppContextProvider>
<AppContextProbe />
</AppContextProvider>
</Suspense>
</TestQueryClientHydrator>
</QueryClientProvider>
</JotaiProvider>,
)
return {
...view,
queryClient,
}
}
describe('AppContextProvider', () => {
beforeEach(() => {
vi.clearAllMocks()
mockPermissionKeysState.isPending = false
mockPermissionKeysState.permissionKeys = ['app.create_and_management']
mockCurrentWorkspaceQueryState.data = mockCurrentWorkspaceResponse
mockCurrentWorkspaceQueryState.isPending = false
mockUserProfileResponseState.data = {
profile: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
avatar: '',
avatar_url: '',
is_password_set: true,
},
meta: {
currentVersion: '1.0.0',
currentEnv: 'cloud',
},
}
mockSystemFeaturesState.data = {
branding: {
enabled: false,
},
}
mockLangGeniusVersionState.data = {
version: '1.0.1',
release_date: '',
release_notes: '',
can_auto_update: false,
}
mockGetRequest.mockImplementation((url: string) => {
if (url === '/workspaces/current/rbac/my-permissions') {
if (mockPermissionKeysState.isPending)
return new Promise(() => {})
return Promise.resolve({
workspace: {
permission_keys: mockPermissionKeysState.permissionKeys,
},
app: {
default_permission_keys: [],
overrides: [],
},
dataset: {
default_permission_keys: [],
overrides: [],
},
})
}
if (url === '/version')
return Promise.resolve(mockLangGeniusVersionState.data)
return Promise.reject(new Error(`Unexpected GET ${url}`))
})
})
describe('Workspace Permission Keys', () => {
it('should provide current workspace permission keys from my-permissions', () => {
render(
<AppContextProvider>
<AppContextProbe />
</AppContextProvider>,
)
describe('Context compatibility values', () => {
it('should provide profile, workspace, permissions, loading state, and version metadata', async () => {
renderProvider()
expect(screen.getByText('keys:app.create_and_management')).toBeInTheDocument()
expect(screen.getByText('loading:false')).toBeInTheDocument()
expect(screen.getByText('role:editor')).toBeInTheDocument()
expect(await screen.findByText('user:user@example.com')).toBeInTheDocument()
expect(await screen.findByText('workspace:Workspace')).toBeInTheDocument()
expect(await screen.findByText('keys:app.create_and_management')).toBeInTheDocument()
expect(screen.getByText('permission loading:false')).toBeInTheDocument()
expect(screen.getByText('workspace loading:false')).toBeInTheDocument()
expect(screen.getByText('workspace validating:false')).toBeInTheDocument()
expect(await screen.findByText('version:1.0.0/1.0.1/cloud')).toBeInTheDocument()
})
it('should fall back to placeholder values when profile, workspace, permission, or version data is missing', async () => {
mockUserProfileResponseState.data = {
meta: {
currentVersion: null,
currentEnv: null,
},
}
mockCurrentWorkspaceQueryState.data = undefined
mockPermissionKeysState.permissionKeys = []
mockLangGeniusVersionState.data = undefined
renderProvider()
expect(await screen.findByText('user:')).toBeInTheDocument()
expect(screen.getByText(`workspace:${initialWorkspaceInfo.name}`)).toBeInTheDocument()
expect(screen.getByText(`role:${initialWorkspaceInfo.role}`)).toBeInTheDocument()
expect(screen.getByText('keys:')).toBeInTheDocument()
expect(screen.getByText('version://')).toBeInTheDocument()
})
it('should normalize invalid workspace roles to the initial workspace role', async () => {
mockCurrentWorkspaceQueryState.data = {
...mockCurrentWorkspaceResponse,
role: 'unsupported-role',
}
renderProvider()
expect(await screen.findByText(`role:${initialWorkspaceInfo.role}`)).toBeInTheDocument()
})
it('should derive role flags from the current workspace role', async () => {
mockCurrentWorkspaceQueryState.data = {
...mockCurrentWorkspaceResponse,
role: 'owner',
}
renderProvider()
expect(await screen.findByText('manager:true')).toBeInTheDocument()
expect(screen.getByText('owner:true')).toBeInTheDocument()
expect(screen.getByText('editor:true')).toBeInTheDocument()
expect(screen.getByText('dataset operator:false')).toBeInTheDocument()
})
it('should expose query loading and validating state', async () => {
mockPermissionKeysState.isPending = true
mockCurrentWorkspaceQueryState.isPending = true
renderProvider()
expect(await screen.findByText('workspace loading:true')).toBeInTheDocument()
expect(screen.getByText('workspace validating:true')).toBeInTheDocument()
expect(screen.getByText('permission loading:true')).toBeInTheDocument()
})
})
describe('Refresh actions', () => {
it('should invalidate the source queries when refresh actions are called', async () => {
const { queryClient } = renderProvider()
const invalidateQueriesSpy = vi.spyOn(queryClient, 'invalidateQueries')
fireEvent.click(await screen.findByRole('button', { name: /refresh user/i }))
fireEvent.click(screen.getByRole('button', { name: /refresh workspace/i }))
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['user-profile'] })
expect(invalidateQueriesSpy).toHaveBeenCalledWith({ queryKey: ['current-workspace'] })
})
})
describe('External side effects', () => {
it('should sync Zendesk fields and Amplitude identity when bootstrap data is available', async () => {
renderProvider()
await waitFor(() => {
expect(setZendeskConversationFields).toHaveBeenCalledWith([{
id: ZENDESK_FIELD_IDS.ENVIRONMENT,
value: 'cloud',
}])
})
expect(setZendeskConversationFields).toHaveBeenCalledWith([{
id: ZENDESK_FIELD_IDS.VERSION,
value: '1.0.1',
}])
expect(setZendeskConversationFields).toHaveBeenCalledWith([{
id: ZENDESK_FIELD_IDS.EMAIL,
value: 'user@example.com',
}])
await waitFor(() => {
expect(setZendeskConversationFields).toHaveBeenCalledWith([{
id: ZENDESK_FIELD_IDS.WORKSPACE_ID,
value: 'workspace-1',
}])
})
await waitFor(() => {
expect(setUserId).toHaveBeenCalledWith('user@example.com')
expect(setUserProperties).toHaveBeenCalledWith(expect.objectContaining({
email: 'user@example.com',
workspace_id: 'workspace-1',
workspace_role: 'editor',
}))
expect(flushRegistrationSuccess).toHaveBeenCalled()
})
})
it('should not sync Amplitude identity when user id is missing', async () => {
mockUserProfileResponseState.data = {
profile: {
id: '',
name: '',
email: '',
avatar: '',
avatar_url: '',
is_password_set: false,
},
meta: {
currentVersion: '1.0.0',
currentEnv: 'cloud',
},
}
renderProvider()
await screen.findByText('user:')
expect(setUserId).not.toHaveBeenCalled()
expect(setUserProperties).not.toHaveBeenCalled()
expect(flushRegistrationSuccess).not.toHaveBeenCalled()
})
})
})

View File

@ -0,0 +1,82 @@
'use client'
import { useAtomValue } from 'jotai'
import { useEffect } from 'react'
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
import { ZENDESK_FIELD_IDS } from '@/config'
import {
currentWorkspaceAtom,
langGeniusVersionInfoAtom,
userProfileAtom,
} from './app-context-state'
export function useSyncZendeskFields() {
const userProfile = useAtomValue(userProfileAtom)
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
useEffect(() => {
if (ZENDESK_FIELD_IDS.ENVIRONMENT && langGeniusVersionInfo?.current_env) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.ENVIRONMENT,
value: langGeniusVersionInfo.current_env.toLowerCase(),
}])
}
}, [langGeniusVersionInfo?.current_env])
useEffect(() => {
if (ZENDESK_FIELD_IDS.VERSION && langGeniusVersionInfo?.version) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.VERSION,
value: langGeniusVersionInfo.version,
}])
}
}, [langGeniusVersionInfo?.version])
useEffect(() => {
if (ZENDESK_FIELD_IDS.EMAIL && userProfile?.email) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.EMAIL,
value: userProfile.email,
}])
}
}, [userProfile?.email])
useEffect(() => {
if (ZENDESK_FIELD_IDS.WORKSPACE_ID && currentWorkspace?.id) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.WORKSPACE_ID,
value: currentWorkspace.id,
}])
}
}, [currentWorkspace?.id])
}
export function useSyncAmplitudeIdentity() {
const userProfile = useAtomValue(userProfileAtom)
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
useEffect(() => {
if (userProfile?.id) {
setUserId(userProfile.email)
const properties: Record<string, string | number | boolean> = {
email: userProfile.email,
name: userProfile.name,
has_password: userProfile.is_password_set,
}
if (currentWorkspace?.id) {
properties.workspace_id = currentWorkspace.id
properties.workspace_name = currentWorkspace.name
properties.workspace_plan = currentWorkspace.plan
properties.workspace_status = currentWorkspace.status
properties.workspace_role = currentWorkspace.role
}
setUserProperties(properties)
flushRegistrationSuccess()
}
}, [userProfile, currentWorkspace])
}

View File

@ -0,0 +1,78 @@
import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { ICurrentWorkspace, LangGeniusVersionResponse } from '@/models/common'
import { initialLangGeniusVersionInfo, initialWorkspaceInfo } from './app-context'
const workspaceRoles = new Set<ICurrentWorkspace['role']>(['owner', 'admin', 'editor', 'dataset_operator', 'normal'])
export const emptyWorkspacePermissionKeys: string[] = []
export type WorkspaceRoleFlags = {
isCurrentWorkspaceManager: boolean
isCurrentWorkspaceOwner: boolean
isCurrentWorkspaceEditor: boolean
isCurrentWorkspaceDatasetOperator: boolean
}
export type ProfileMeta = {
currentVersion: string | null
currentEnv: string | null
}
function resolveWorkspaceRole(role: PostWorkspacesCurrentResponse['role']): ICurrentWorkspace['role'] {
if (role && workspaceRoles.has(role as ICurrentWorkspace['role']))
return role as ICurrentWorkspace['role']
return initialWorkspaceInfo.role
}
export function normalizeCurrentWorkspace(workspace?: PostWorkspacesCurrentResponse): ICurrentWorkspace {
if (!workspace)
return initialWorkspaceInfo
return {
id: workspace.id,
name: workspace.name ?? initialWorkspaceInfo.name,
plan: workspace.plan ?? initialWorkspaceInfo.plan,
status: workspace.status ?? initialWorkspaceInfo.status,
created_at: workspace.created_at ?? initialWorkspaceInfo.created_at,
role: resolveWorkspaceRole(workspace.role),
providers: initialWorkspaceInfo.providers,
trial_credits: workspace.trial_credits ?? initialWorkspaceInfo.trial_credits,
trial_credits_used: workspace.trial_credits_used ?? initialWorkspaceInfo.trial_credits_used,
next_credit_reset_date: workspace.next_credit_reset_date ?? initialWorkspaceInfo.next_credit_reset_date,
trial_end_reason: workspace.trial_end_reason ?? undefined,
custom_config: workspace.custom_config
? {
remove_webapp_brand: workspace.custom_config.remove_webapp_brand ?? undefined,
replace_webapp_logo: workspace.custom_config.replace_webapp_logo ?? undefined,
}
: undefined,
}
}
export function getWorkspaceRoleFlags(currentWorkspace: ICurrentWorkspace): WorkspaceRoleFlags {
return {
isCurrentWorkspaceManager: ['owner', 'admin'].includes(currentWorkspace.role),
isCurrentWorkspaceOwner: currentWorkspace.role === 'owner',
isCurrentWorkspaceEditor: ['owner', 'admin', 'editor'].includes(currentWorkspace.role),
isCurrentWorkspaceDatasetOperator: currentWorkspace.role === 'dataset_operator',
}
}
export function getLangGeniusVersionInfo({
meta,
versionData,
}: {
meta: ProfileMeta
versionData?: Omit<LangGeniusVersionResponse, 'current_version' | 'latest_version' | 'current_env'>
}): LangGeniusVersionResponse {
if (!meta.currentVersion || !versionData)
return initialLangGeniusVersionInfo
return {
...versionData,
current_version: meta.currentVersion,
latest_version: versionData.version,
current_env: meta.currentEnv || '',
}
}

View File

@ -1,203 +1,68 @@
'use client'
import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen'
import type { PostWorkspacesCurrentResponse } from '@dify/contracts/api/console/workspaces/types.gen'
import type { FC, ReactNode } from 'react'
import type { ICurrentWorkspace, LangGeniusVersionResponse } from '@/models/common'
import { useQuery, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo } from 'react'
import { setUserId, setUserProperties } from '@/app/components/base/amplitude'
import { flushRegistrationSuccess } from '@/app/components/base/amplitude/registration-tracking'
import { setZendeskConversationFields } from '@/app/components/base/zendesk/utils'
import MaintenanceNotice from '@/app/components/header/maintenance-notice'
import { ZENDESK_FIELD_IDS } from '@/config'
import type { ReactNode } from 'react'
import { useAtomValue, useSetAtom } from 'jotai'
import {
AppContext,
initialLangGeniusVersionInfo,
initialWorkspaceInfo,
userProfilePlaceholder,
useSelector,
} from '@/context/app-context'
import { env } from '@/env'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { useWorkspacePermissionKeys } from '@/service/access-control/use-permission-keys'
import { consoleQuery } from '@/service/client'
import {
useLangGeniusVersion,
} from '@/service/use-common'
currentWorkspaceAtom,
currentWorkspaceLoadingAtom,
currentWorkspaceValidatingAtom,
langGeniusVersionInfoAtom,
refreshCurrentWorkspaceAtom,
refreshUserProfileAtom,
userProfileAtom,
workspacePermissionKeysAtom,
workspacePermissionKeysLoadingAtom,
workspaceRoleFlagsAtom,
} from '@/context/app-context-state'
import {
useSyncAmplitudeIdentity,
useSyncZendeskFields,
} from './app-context-effects'
type AppContextProviderProps = {
children: ReactNode
}
const workspaceRoles = new Set<ICurrentWorkspace['role']>(['owner', 'admin', 'editor', 'dataset_operator', 'normal'])
const emptyWorkspacePermissionKeys: string[] = []
export function AppContextProvider({ children }: AppContextProviderProps) {
const userProfile = useAtomValue(userProfileAtom)
const currentWorkspace = useAtomValue(currentWorkspaceAtom)
const roleFlags = useAtomValue(workspaceRoleFlagsAtom)
const workspacePermissionKeys = useAtomValue(workspacePermissionKeysAtom)
const langGeniusVersionInfo = useAtomValue(langGeniusVersionInfoAtom)
const isLoadingCurrentWorkspace = useAtomValue(currentWorkspaceLoadingAtom)
const isValidatingCurrentWorkspace = useAtomValue(currentWorkspaceValidatingAtom)
const isLoadingWorkspacePermissionKeys = useAtomValue(workspacePermissionKeysLoadingAtom)
const resolveWorkspaceRole = (role: PostWorkspacesCurrentResponse['role']): ICurrentWorkspace['role'] => {
if (role && workspaceRoles.has(role as ICurrentWorkspace['role']))
return role as ICurrentWorkspace['role']
const refreshUserProfile = useSetAtom(refreshUserProfileAtom)
const refreshCurrentWorkspace = useSetAtom(refreshCurrentWorkspaceAtom)
return initialWorkspaceInfo.role
}
const normalizeCurrentWorkspace = (workspace?: PostWorkspacesCurrentResponse): ICurrentWorkspace => {
if (!workspace)
return initialWorkspaceInfo
return {
id: workspace.id,
name: workspace.name ?? initialWorkspaceInfo.name,
plan: workspace.plan ?? initialWorkspaceInfo.plan,
status: workspace.status ?? initialWorkspaceInfo.status,
created_at: workspace.created_at ?? initialWorkspaceInfo.created_at,
role: resolveWorkspaceRole(workspace.role),
providers: initialWorkspaceInfo.providers,
trial_credits: workspace.trial_credits ?? initialWorkspaceInfo.trial_credits,
trial_credits_used: workspace.trial_credits_used ?? initialWorkspaceInfo.trial_credits_used,
next_credit_reset_date: workspace.next_credit_reset_date ?? initialWorkspaceInfo.next_credit_reset_date,
trial_end_reason: workspace.trial_end_reason ?? undefined,
custom_config: workspace.custom_config
? {
remove_webapp_brand: workspace.custom_config.remove_webapp_brand ?? undefined,
replace_webapp_logo: workspace.custom_config.replace_webapp_logo ?? undefined,
}
: undefined,
}
}
export const AppContextProvider: FC<AppContextProviderProps> = ({ children }) => {
const queryClient = useQueryClient()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const { data: userProfileResp } = useSuspenseQuery(userProfileQueryOptions())
const currentWorkspaceQuery = useQuery(consoleQuery.workspaces.current.post.queryOptions({
select: normalizeCurrentWorkspace,
}))
const workspacePermissionKeysQuery = useWorkspacePermissionKeys()
const langGeniusVersionQuery = useLangGeniusVersion(
userProfileResp?.meta.currentVersion,
!systemFeatures.branding.enabled,
)
const userProfile = useMemo<GetAccountProfileResponse>(() => userProfileResp?.profile || userProfilePlaceholder, [userProfileResp?.profile])
const currentWorkspace = currentWorkspaceQuery.data ?? initialWorkspaceInfo
const langGeniusVersionInfo = useMemo<LangGeniusVersionResponse>(() => {
if (!userProfileResp?.meta?.currentVersion || !langGeniusVersionQuery.data)
return initialLangGeniusVersionInfo
const current_version = userProfileResp.meta.currentVersion
const current_env = userProfileResp.meta.currentEnv || ''
const versionData = langGeniusVersionQuery.data
return {
...versionData,
current_version,
latest_version: versionData.version,
current_env,
}
}, [langGeniusVersionQuery.data, userProfileResp?.meta])
const isCurrentWorkspaceManager = useMemo(() => ['owner', 'admin'].includes(currentWorkspace.role), [currentWorkspace.role])
const isCurrentWorkspaceOwner = useMemo(() => currentWorkspace.role === 'owner', [currentWorkspace.role])
const isCurrentWorkspaceEditor = useMemo(() => ['owner', 'admin', 'editor'].includes(currentWorkspace.role), [currentWorkspace.role])
const isCurrentWorkspaceDatasetOperator = useMemo(() => currentWorkspace.role === 'dataset_operator', [currentWorkspace.role])
const mutateUserProfile = useCallback(() => {
queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey })
}, [queryClient])
const mutateCurrentWorkspace = useCallback(() => {
queryClient.invalidateQueries({ queryKey: consoleQuery.workspaces.current.post.key() })
}, [queryClient])
// #region Zendesk conversation fields
useEffect(() => {
if (ZENDESK_FIELD_IDS.ENVIRONMENT && langGeniusVersionInfo?.current_env) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.ENVIRONMENT,
value: langGeniusVersionInfo.current_env.toLowerCase(),
}])
}
}, [langGeniusVersionInfo?.current_env])
useEffect(() => {
if (ZENDESK_FIELD_IDS.VERSION && langGeniusVersionInfo?.version) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.VERSION,
value: langGeniusVersionInfo.version,
}])
}
}, [langGeniusVersionInfo?.version])
useEffect(() => {
if (ZENDESK_FIELD_IDS.EMAIL && userProfile?.email) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.EMAIL,
value: userProfile.email,
}])
}
}, [userProfile?.email])
useEffect(() => {
if (ZENDESK_FIELD_IDS.WORKSPACE_ID && currentWorkspace?.id) {
setZendeskConversationFields([{
id: ZENDESK_FIELD_IDS.WORKSPACE_ID,
value: currentWorkspace.id,
}])
}
}, [currentWorkspace?.id])
// #endregion Zendesk conversation fields
useEffect(() => {
// Report user and workspace info to Amplitude when loaded
if (userProfile?.id) {
setUserId(userProfile.email)
const properties: Record<string, string | number | boolean> = {
email: userProfile.email,
name: userProfile.name,
has_password: userProfile.is_password_set,
}
if (currentWorkspace?.id) {
properties.workspace_id = currentWorkspace.id
properties.workspace_name = currentWorkspace.name
properties.workspace_plan = currentWorkspace.plan
properties.workspace_status = currentWorkspace.status
properties.workspace_role = currentWorkspace.role
}
setUserProperties(properties)
// The user ID is now attached, so replay any registration success event captured
// at signup time. This makes it land on the identified Amplitude profile instead
// of an anonymous one (no-op when nothing was deferred).
flushRegistrationSuccess()
}
}, [userProfile, currentWorkspace])
useSyncZendeskFields()
useSyncAmplitudeIdentity()
return (
<AppContext.Provider value={{
userProfile,
mutateUserProfile,
mutateUserProfile: () => {
refreshUserProfile()
},
langGeniusVersionInfo,
useSelector,
currentWorkspace,
isCurrentWorkspaceManager,
isCurrentWorkspaceOwner,
isCurrentWorkspaceEditor,
isCurrentWorkspaceDatasetOperator,
mutateCurrentWorkspace,
isLoadingCurrentWorkspace: currentWorkspaceQuery.isPending,
isLoadingWorkspacePermissionKeys: workspacePermissionKeysQuery.isPending,
isValidatingCurrentWorkspace: currentWorkspaceQuery.isFetching,
workspacePermissionKeys: workspacePermissionKeysQuery.data?.workspace.permission_keys ?? emptyWorkspacePermissionKeys,
...roleFlags,
mutateCurrentWorkspace: () => {
refreshCurrentWorkspace()
},
isLoadingCurrentWorkspace,
isLoadingWorkspacePermissionKeys,
isValidatingCurrentWorkspace,
workspacePermissionKeys,
}}
>
<div className="flex h-full flex-col overflow-hidden">
{env.NEXT_PUBLIC_MAINTENANCE_NOTICE && <MaintenanceNotice />}
<div className="relative flex h-0 min-h-0 grow flex-col overflow-hidden bg-background-body">
{children}
</div>
</div>
{children}
</AppContext.Provider>
)
}

View File

@ -0,0 +1,116 @@
'use client'
import type { GetAccountProfileResponse } from '@dify/contracts/api/console/account/types.gen'
import type { GetSystemFeaturesResponse } from '@dify/contracts/api/console/system-features/types.gen'
import type { DefinedQueryObserverResult } from '@tanstack/react-query'
import type { UserProfileWithMeta } from '@/features/account-profile/client'
import { atom } from 'jotai'
import { atomWithQuery, atomWithSuspenseQuery, queryClientAtom } from 'jotai-tanstack-query'
import { userProfileQueryOptions } from '@/features/account-profile/client'
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
import { workspacePermissionKeysQueryOptions } from '@/service/access-control/use-permission-keys'
import { consoleQuery } from '@/service/client'
import { langGeniusVersionQueryOptions } from '@/service/lang-genius-version'
import {
initialLangGeniusVersionInfo,
initialWorkspaceInfo,
userProfilePlaceholder,
} from './app-context'
import {
emptyWorkspacePermissionKeys,
getLangGeniusVersionInfo,
getWorkspaceRoleFlags,
normalizeCurrentWorkspace,
} from './app-context-normalizers'
type SuspenseQueryResult<T> = Omit<DefinedQueryObserverResult<T>, 'isPlaceholderData'>
const accountProfileQueryAtom = atomWithSuspenseQuery(() => userProfileQueryOptions())
const systemFeaturesQueryAtom = atomWithSuspenseQuery(() => systemFeaturesQueryOptions())
export const userProfileAtom = atom((get): GetAccountProfileResponse => {
const accountProfileQuery = get(accountProfileQueryAtom) as SuspenseQueryResult<UserProfileWithMeta>
return accountProfileQuery.data?.profile || userProfilePlaceholder
})
const profileMetaAtom = atom((get) => {
const accountProfileQuery = get(accountProfileQueryAtom) as SuspenseQueryResult<UserProfileWithMeta>
return accountProfileQuery.data?.meta ?? {
currentVersion: null,
currentEnv: null,
}
})
const currentWorkspaceQueryAtom = atomWithQuery(() => {
return consoleQuery.workspaces.current.post.queryOptions({
select: normalizeCurrentWorkspace,
})
})
const normalizedCurrentWorkspaceAtom = atom((get) => {
return get(currentWorkspaceQueryAtom).data ?? initialWorkspaceInfo
})
export const currentWorkspaceAtom = atom((get) => {
return get(normalizedCurrentWorkspaceAtom)
})
export const workspaceRoleFlagsAtom = atom((get) => {
return getWorkspaceRoleFlags(get(currentWorkspaceAtom))
})
const workspacePermissionKeysQueryAtom = atomWithQuery((get) => {
const workspaceId = get(currentWorkspaceAtom).id
return workspacePermissionKeysQueryOptions(workspaceId)
})
export const workspacePermissionKeysAtom = atom((get) => {
return get(workspacePermissionKeysQueryAtom).data?.workspace.permission_keys ?? emptyWorkspacePermissionKeys
})
export const workspacePermissionKeysLoadingAtom = atom((get) => {
return get(workspacePermissionKeysQueryAtom).isPending
})
export const currentWorkspaceLoadingAtom = atom((get) => {
return get(currentWorkspaceQueryAtom).isPending
})
export const currentWorkspaceValidatingAtom = atom((get) => {
return get(currentWorkspaceQueryAtom).isFetching
})
const versionQueryAtom = atomWithQuery((get) => {
const meta = get(profileMetaAtom)
const systemFeaturesQuery = get(systemFeaturesQueryAtom) as SuspenseQueryResult<GetSystemFeaturesResponse>
const enabled = Boolean(meta.currentVersion && !systemFeaturesQuery.data?.branding.enabled)
return langGeniusVersionQueryOptions(meta.currentVersion, enabled)
})
export const langGeniusVersionInfoAtom = atom((get) => {
const meta = get(profileMetaAtom)
const versionData = get(versionQueryAtom).data
if (!versionData)
return initialLangGeniusVersionInfo
return getLangGeniusVersionInfo({
meta,
versionData,
})
})
export const refreshUserProfileAtom = atom(null, (get) => {
const queryClient = get(queryClientAtom)
queryClient.invalidateQueries({ queryKey: userProfileQueryOptions().queryKey })
})
export const refreshCurrentWorkspaceAtom = atom(null, (get) => {
const queryClient = get(queryClientAtom)
queryClient.invalidateQueries({ queryKey: consoleQuery.workspaces.current.post.key() })
})

View File

@ -57,7 +57,7 @@ export NEXT_PUBLIC_ENABLE_WEBSITE_JINAREADER=${ENABLE_WEBSITE_JINAREADER:-true}
export NEXT_PUBLIC_ENABLE_WEBSITE_FIRECRAWL=${ENABLE_WEBSITE_FIRECRAWL:-true}
export NEXT_PUBLIC_ENABLE_WEBSITE_WATERCRAWL=${ENABLE_WEBSITE_WATERCRAWL:-true}
export NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX=${NEXT_PUBLIC_ENABLE_SINGLE_DOLLAR_LATEX:-false}
export NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=${NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW:-false}
export NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW=${NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW:-true}
export NEXT_PUBLIC_ENABLE_AGENT_V2=${NEXT_PUBLIC_ENABLE_AGENT_V2:-${ENABLE_AGENT_V2:-false}}
export NEXT_PUBLIC_LOOP_NODE_MAX_COUNT=${LOOP_NODE_MAX_COUNT}
export NEXT_PUBLIC_MAX_PARALLEL_LIMIT=${MAX_PARALLEL_LIMIT}

View File

@ -69,7 +69,7 @@ const clientSchema = {
* Currently gates the `/create` and `/refine` slash commands in the
* "Go to Anything" command palette (Cmd/Ctrl+K).
*/
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW: coercedBoolean.default(false),
NEXT_PUBLIC_ENABLE_FEATURE_PREVIEW: coercedBoolean.default(true),
/**
* Cloud-only system-features defaults.

View File

@ -1,6 +1,6 @@
import type { AgentAppDetailWithSite } from '@dify/contracts/api/console/agent/types.gen'
import { render, screen } from '@testing-library/react'
import { AgentDetailSection } from '../navigation'
import { AgentDetailSection, AgentDetailTop } from '../navigation'
const mocks = vi.hoisted(() => ({
pathname: '/roster/agent/agent-1/configure',
@ -71,3 +71,17 @@ describe('AgentDetailSection', () => {
expect(agentName.parentElement?.parentElement).toHaveClass('h-13', 'py-1.5', 'pl-1.5', 'pr-2')
})
})
describe('AgentDetailTop', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('links the combined home control to home', () => {
render(<AgentDetailTop />)
expect(screen.getByRole('link', { name: 'common.mainNav.home' })).toHaveAttribute('href', '/')
expect(screen.getByRole('link', { name: 'common.menus.roster' })).toHaveAttribute('href', '/roster')
expect(screen.queryByRole('button', { name: 'common.operation.back' })).not.toBeInTheDocument()
})
})

View File

@ -1,5 +1,5 @@
import { toast } from '@langgenius/dify-ui/toast'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-composer/form-state'
@ -120,41 +120,6 @@ describe('AgentEnvEditor', () => {
expect(screen.getByDisplayValue('SECOND_KEY')).toBeInTheDocument()
})
it('should preserve a newly added variable key when key and value change in the same batch', async () => {
const user = userEvent.setup()
render(
<AgentComposerProvider
initialDraft={{
...defaultAgentSoulConfigFormState,
envVariables: [{
id: 'env-1',
key: 'API_KEY',
value: 'secret-value',
scope: 'plain',
}],
}}
>
<AgentEnvEditor />
</AgentComposerProvider>,
)
await user.click(screen.getByRole('button', { name: 'agentV2.agentDetail.configure.advancedSettings.envEditor.add' }))
const keyInputs = screen.getAllByPlaceholderText('agentV2.agentDetail.configure.advancedSettings.envEditor.keyPlaceholder')
const valueInputs = screen.getAllByPlaceholderText('agentV2.agentDetail.configure.advancedSettings.envEditor.valuePlaceholder')
const newKeyInput = keyInputs[1]!
const newValueInput = valueInputs[1]!
act(() => {
fireEvent.change(newKeyInput, { target: { value: 'SECOND_KEY' } })
fireEvent.change(newValueInput, { target: { value: 'plain' } })
})
expect(newKeyInput).toHaveValue('SECOND_KEY')
expect(newValueInput).toHaveValue('plain')
})
it('should import dotenv variables into the env table when a file is selected', async () => {
const user = userEvent.setup()
const { container } = renderAgentEnvEditor()

View File

@ -436,23 +436,6 @@ export function AgentEnvEditor() {
const envEditorTableId = 'agent-configure-env-editor-table'
const visibleEnvVariables = envVariables.length > 0 ? envVariables : [starterVariable]
const updateVariable = (id: string, updater: (variable: EnvVariable) => EnvVariable) => {
setEnvVariables((currentEnvVariables) => {
const existingVariable = currentEnvVariables.find(variable => variable.id === id)
if (existingVariable) {
return currentEnvVariables.map(variable => (
variable.id === id ? updater(variable) : variable
))
}
if (id === starterVariable.id)
return [updater(starterVariable)]
return currentEnvVariables
})
}
const addVariable = ({
focusField = 'key',
scope,
@ -465,8 +448,8 @@ export function AgentEnvEditor() {
...(scope ? { scope } : {}),
}
setEnvVariables(currentEnvVariables => [
...(currentEnvVariables.length > 0 ? currentEnvVariables : [starterVariable]),
addEnvVariable({
starterVariable,
variable,
})
setFocusedVariable({ id: variable.id, field: focusField })
@ -487,7 +470,7 @@ export function AgentEnvEditor() {
if (importedVariables.length === 0)
return
setEnvVariables(currentEnvVariables => [...currentEnvVariables, ...importedVariables])
importEnvVariables(importedVariables)
}
const updateVariableKey = (id: string, key: string) => {
setEnvVariableKey({ id, key, starterVariable })
@ -499,7 +482,7 @@ export function AgentEnvEditor() {
setEnvVariableValue({ id, starterVariable, value })
}
const deleteVariable = (id: string) => {
setEnvVariables(currentEnvVariables => currentEnvVariables.filter(variable => variable.id !== id))
removeEnvVariable(id)
}
return (

View File

@ -17,7 +17,7 @@ import Divider from '@/app/components/base/divider'
import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/SidebarLeftArrowIcon'
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
import Link from '@/next/link'
import { usePathname, useRouter } from '@/next/navigation'
import { usePathname } from '@/next/navigation'
import { consoleQuery } from '@/service/client'
import { getAgentDetailPath, getAgentIdFromPathname } from './routes'
@ -88,7 +88,6 @@ export function AgentDetailTop({
}: AgentDetailTopProps) {
const { t: tApp } = useTranslation('app')
const { t: tCommon } = useTranslation('common')
const router = useRouter()
const setGotoAnythingOpen = useSetGotoAnythingOpen()
if (!expand) {
@ -109,23 +108,14 @@ export function AgentDetailTop({
return (
<div className="flex items-center py-2 pr-2 pl-1">
<div className="flex min-w-0 flex-1 items-center gap-px">
<div className="flex shrink-0 items-center rounded-lg py-2 pr-1.5 pl-0.5 transition-colors hover:bg-background-default-hover">
<button
type="button"
aria-label={tCommon('operation.back')}
className="flex size-4 items-center justify-center text-text-tertiary hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
onClick={() => router.back()}
>
<span aria-hidden className="i-ri-arrow-left-s-line size-4" />
</button>
<Link
href="/"
aria-label={tCommon('mainNav.home')}
className="flex size-4 items-center justify-center text-text-tertiary hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
<span aria-hidden className="i-custom-vender-main-nav-app-home size-4" />
</Link>
</div>
<Link
href="/"
aria-label={tCommon('mainNav.home')}
className="flex shrink-0 items-center rounded-lg py-2 pr-1.5 pl-0.5 text-text-tertiary transition-colors hover:bg-background-default-hover hover:text-text-secondary focus-visible:ring-2 focus-visible:ring-state-accent-solid focus-visible:outline-hidden"
>
<span aria-hidden className="i-ri-arrow-left-s-line size-4" />
<span aria-hidden className="i-custom-vender-main-nav-app-home size-4" />
</Link>
<span className="shrink-0 system-md-regular text-text-quaternary">
/
</span>

View File

@ -4,6 +4,7 @@ import { Provider as JotaiProvider } from 'jotai'
import { NextRouteStateBridge } from '@/app/components/next-route-state'
import { useParams, usePathname } from '@/next/navigation'
import { InstanceDetail } from '..'
import { DeploymentDetailTop } from '../deployment-sidebar'
vi.mock('@/next/navigation', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/next/navigation')>()
@ -60,3 +61,21 @@ describe('InstanceDetail', () => {
expect(screen.queryByRole('complementary', { name: 'Detail sidebar' })).not.toBeInTheDocument()
})
})
describe('DeploymentDetailTop', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('links the combined home control to home', () => {
render(
<JotaiProvider>
<DeploymentDetailTop />
</JotaiProvider>,
)
expect(screen.getByRole('link', { name: 'common.mainNav.home' })).toHaveAttribute('href', '/')
expect(screen.getByRole('link', { name: 'common.menus.deployments' })).toHaveAttribute('href', '/deployments')
expect(screen.queryByRole('button', { name: 'common.operation.back' })).not.toBeInTheDocument()
})
})

View File

@ -16,7 +16,7 @@ import SidebarLeftArrowIcon from '@/app/components/base/icons/src/vender/Sidebar
import { SkeletonContainer, SkeletonRectangle } from '@/app/components/base/skeleton'
import { useSetGotoAnythingOpen } from '@/app/components/goto-anything/atoms'
import Link from '@/next/link'
import { usePathname, useRouter } from '@/next/navigation'
import { usePathname } from '@/next/navigation'
import { DeploymentActionsMenu } from '../deployment-actions'
import { deploymentRouteAppInstanceIdAtom } from '../route-state'
import { TitleTooltip } from '../shared/components/title-tooltip'
@ -187,7 +187,6 @@ export function DeploymentDetailTop({
onToggle?: () => void
}) {
const { t } = useTranslation()
const router = useRouter()
const setGotoAnythingOpen = useSetGotoAnythingOpen()
if (!expand) {
@ -208,23 +207,14 @@ export function DeploymentDetailTop({
return (
<div className="flex items-center py-2 pr-2 pl-1">
<div className="flex min-w-0 flex-1 items-center gap-px">
<div className="flex shrink-0 items-center rounded-lg py-2 pr-1.5 pl-0.5 transition-colors hover:bg-background-default-hover">
<button
type="button"
aria-label={t('operation.back', { ns: 'common' })}
className="flex size-4 items-center justify-center text-text-tertiary hover:text-text-secondary"
onClick={() => router.back()}
>
<span aria-hidden className="i-ri-arrow-left-s-line size-4" />
</button>
<Link
href="/"
aria-label={t('mainNav.home', { ns: 'common' })}
className="flex size-4 items-center justify-center text-text-tertiary hover:text-text-secondary"
>
<span aria-hidden className="i-custom-vender-main-nav-app-home size-4" />
</Link>
</div>
<Link
href="/"
aria-label={t('mainNav.home', { ns: 'common' })}
className="flex shrink-0 items-center rounded-lg py-2 pr-1.5 pl-0.5 text-text-tertiary transition-colors hover:bg-background-default-hover hover:text-text-secondary"
>
<span aria-hidden className="i-ri-arrow-left-s-line size-4" />
<span aria-hidden className="i-custom-vender-main-nav-app-home size-4" />
</Link>
<span className="shrink-0 system-md-regular text-text-quaternary">
/
</span>

View File

@ -1,26 +1,13 @@
import type { ReactNode } from 'react'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { renderHook, waitFor } from '@testing-library/react'
import { QueryClient } from '@tanstack/react-query'
// eslint-disable-next-line no-restricted-imports
import { get } from '@/service/base'
import { useWorkspacePermissionKeys } from '../use-permission-keys'
import { workspacePermissionKeysQueryOptions } from '../use-permission-keys'
vi.mock('@/service/base', () => ({
get: vi.fn(),
}))
const createWrapper = () => {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
})
return ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
)
}
describe('useWorkspacePermissionKeys', () => {
describe('workspacePermissionKeysQueryOptions', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.mocked(get).mockResolvedValue({
@ -33,11 +20,15 @@ describe('useWorkspacePermissionKeys', () => {
// Current-user permissions come from the my-permissions RBAC endpoint.
describe('Queries', () => {
it('should fetch workspace permission keys', async () => {
renderHook(() => useWorkspacePermissionKeys(), { wrapper: createWrapper() })
await waitFor(() => {
expect(get).toHaveBeenCalledWith('/workspaces/current/rbac/my-permissions')
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
},
})
await queryClient.fetchQuery(workspacePermissionKeysQueryOptions())
expect(get).toHaveBeenCalledWith('/workspaces/current/rbac/my-permissions')
})
})
})

View File

@ -1,12 +1,18 @@
import type { PermissionKeysResponse } from '@/models/access-control'
import { useQuery } from '@tanstack/react-query'
import { queryOptions } from '@tanstack/react-query'
// eslint-disable-next-line no-restricted-imports
import { get } from '../base'
const NAME_SPACE = 'workspace-permission-keys'
export const useWorkspacePermissionKeys = () => {
return useQuery({
queryKey: [NAME_SPACE],
const workspacePermissionKeysQueryKey = (workspaceId?: string) => {
return workspaceId ? [NAME_SPACE, workspaceId] as const : [NAME_SPACE] as const
}
export const workspacePermissionKeysQueryOptions = (workspaceId?: string) => {
return queryOptions<PermissionKeysResponse>({
queryKey: workspacePermissionKeysQueryKey(workspaceId),
queryFn: () => get<PermissionKeysResponse>('/workspaces/current/rbac/my-permissions'),
enabled: workspaceId === undefined || Boolean(workspaceId),
})
}

View File

@ -0,0 +1,13 @@
import type { LangGeniusVersionResponse } from '@/models/common'
import { queryOptions } from '@tanstack/react-query'
// eslint-disable-next-line no-restricted-imports
import { get } from './base'
import { commonQueryKeys } from './use-common'
export const langGeniusVersionQueryOptions = (currentVersion?: string | null, enabled?: boolean) => {
return queryOptions<LangGeniusVersionResponse>({
queryKey: commonQueryKeys.langGeniusVersion(currentVersion || undefined),
queryFn: () => get<LangGeniusVersionResponse>('/version', { params: { current_version: currentVersion } }),
enabled: !!currentVersion && (enabled ?? true),
})
}

View File

@ -10,13 +10,13 @@ import type {
CodeBasedExtension,
CommonResponse,
FileUploadConfigResponse,
LangGeniusVersionResponse,
Member,
StructuredOutputRulesRequestBody,
StructuredOutputRulesResponse,
} from '@/models/common'
import type { RETRIEVE_METHOD } from '@/types/app'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
// eslint-disable-next-line no-restricted-imports
import { get, post } from './base'
const NAME_SPACE = 'common'
@ -54,14 +54,6 @@ export const useFileUploadConfig = () => {
})
}
export const useLangGeniusVersion = (currentVersion?: string | null, enabled?: boolean) => {
return useQuery<LangGeniusVersionResponse>({
queryKey: commonQueryKeys.langGeniusVersion(currentVersion || undefined),
queryFn: () => get<LangGeniusVersionResponse>('/version', { params: { current_version: currentVersion } }),
enabled: !!currentVersion && (enabled ?? true),
})
}
export const useGenerateStructuredOutputRules = () => {
return useMutation({
mutationKey: [NAME_SPACE, 'generate-structured-output-rules'],
@ -95,7 +87,7 @@ export const useMailValidity = () => {
})
}
export type MailRegisterResponse = { result: string, data: {} }
export type MailRegisterResponse = { result: string, data: Record<string, never> }
export const useMailRegister = () => {
return useMutation({
@ -149,7 +141,7 @@ export const useFilePreview = (fileID: string) => {
export type SchemaTypeDefinition = {
name: string
schema: {
properties: Record<string, any>
properties: Record<string, unknown>
}
}