Compare commits

..

3 Commits

65 changed files with 954 additions and 3480 deletions

View File

@ -90,12 +90,10 @@ class WorkflowAgentComposerValidateApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
@with_current_tenant_id
def post(self, tenant_id: str, app_model: App, node_id: str):
def post(self, app_model: App, node_id: str):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
findings = AgentComposerService.collect_validation_findings(tenant_id=tenant_id, payload=payload)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/nodes/<string:node_id>/agent-composer/candidates")
@ -107,17 +105,10 @@ class WorkflowAgentComposerCandidatesApi(Resource):
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.WORKFLOW, AppMode.ADVANCED_CHAT])
@with_current_user_id
@with_current_tenant_id
def get(self, tenant_id: str, current_user_id: str, app_model: App, node_id: str):
def get(self, app_model: App, node_id: str):
return dump_response(
AgentComposerCandidatesResponse,
AgentComposerService.get_workflow_candidates(
tenant_id=tenant_id,
app_id=app_model.id,
node_id=node_id,
user_id=current_user_id,
),
AgentComposerService.get_workflow_candidates(app_id=app_model.id),
)
@ -176,7 +167,7 @@ class AgentAppComposerApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@get_app_model()
@with_current_tenant_id
def get(self, tenant_id: str, app_model: App):
return dump_response(
@ -190,7 +181,7 @@ class AgentAppComposerApi(Resource):
@login_required
@account_initialization_required
@edit_permission_required
@get_app_model(mode=[AppMode.AGENT])
@get_app_model()
@with_current_user_id
@with_current_tenant_id
def put(self, tenant_id: str, account_id: str, app_model: App):
@ -215,13 +206,11 @@ class AgentAppComposerValidateApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_tenant_id
def post(self, tenant_id: str, app_model: App):
@get_app_model()
def post(self, app_model: App):
payload = ComposerSavePayload.model_validate(console_ns.payload or {})
ComposerConfigValidator.validate_save_payload(payload)
findings = AgentComposerService.collect_validation_findings(tenant_id=tenant_id, payload=payload)
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": [], **findings})
return dump_response(AgentComposerValidateResponse, {"result": "success", "errors": []})
@console_ns.route("/apps/<uuid:app_id>/agent-composer/candidates")
@ -232,15 +221,9 @@ class AgentAppComposerCandidatesApi(Resource):
@setup_required
@login_required
@account_initialization_required
@get_app_model(mode=[AppMode.AGENT])
@with_current_user_id
@with_current_tenant_id
def get(self, tenant_id: str, current_user_id: str, app_model: App):
@get_app_model()
def get(self, app_model: App):
return dump_response(
AgentComposerCandidatesResponse,
AgentComposerService.get_agent_app_candidates(
tenant_id=tenant_id,
app_id=app_model.id,
user_id=current_user_id,
),
AgentComposerService.get_agent_app_candidates(app_id=app_model.id),
)

View File

@ -2,17 +2,15 @@ from flask import request
from flask_restx import Resource
from pydantic import BaseModel, Field, field_validator
from configs import dify_config
from constants.languages import supported_language
from controllers.common.schema import register_schema_models
from controllers.console import console_ns
from controllers.console.error import AccountInFreezeError, AlreadyActivateError
from controllers.console.error import AlreadyActivateError
from extensions.ext_database import db
from libs.datetime_utils import naive_utc_now
from libs.helper import EmailStr, timezone
from models import AccountStatus
from services.account_service import RegisterService
from services.billing_service import BillingService
class ActivateCheckQuery(BaseModel):
@ -122,12 +120,9 @@ class ActivateApi(Resource):
if invitation is None:
raise AlreadyActivateError()
account = invitation["account"]
if dify_config.BILLING_ENABLED and BillingService.is_email_in_freeze(account.email):
raise AccountInFreezeError()
RegisterService.revoke_token(args.workspace_id, normalized_request_email, args.token)
account = invitation["account"]
account.name = args.name
account.interface_language = args.interface_language

View File

@ -1,7 +1,6 @@
from flask import Blueprint
from flask_restx import Namespace
from controllers.openapi._errors import ErrorBody, OpenApiErrorFormatter
from libs.device_flow_security import attach_anti_framing
from libs.external_api import ExternalApi
@ -13,7 +12,6 @@ api = ExternalApi(
version="1.0",
title="OpenAPI",
description="User-scoped programmatic API (bearer auth)",
error_body_formatter=OpenApiErrorFormatter(),
)
openapi_ns = Namespace("openapi", description="User-scoped operations", path="/")
@ -91,7 +89,6 @@ register_schema_models(
)
register_response_schema_models(
openapi_ns,
ErrorBody,
TagItem,
UsageInfo,
MessageMetadata,

View File

@ -21,7 +21,6 @@ from pydantic import BaseModel, ValidationError
from controllers.common.schema import query_params_from_model, query_params_from_request
from controllers.openapi import openapi_ns
from controllers.openapi._errors import ErrorBody
def accepts(*, query: type[BaseModel] | None = None, body: type[BaseModel] | None = None) -> Callable:
@ -52,8 +51,6 @@ def accepts(*, query: type[BaseModel] | None = None, body: type[BaseModel] | Non
openapi_ns.doc(params=query_params_from_model(query))(wrapper)
if body is not None:
openapi_ns.expect(openapi_ns.models[body.__name__])(wrapper)
if query is not None or body is not None:
openapi_ns.response(422, "Validation error", openapi_ns.models[ErrorBody.__name__])(wrapper)
return wrapper
return decorator
@ -79,7 +76,6 @@ def returns(code: int, model: type[BaseModel], description: str | None = None) -
return result
openapi_ns.response(code, description or model.__name__, openapi_ns.models[model.__name__])(wrapper)
openapi_ns.response("default", "Error", openapi_ns.models[ErrorBody.__name__])(wrapper)
return wrapper
return decorator

View File

@ -1,241 +0,0 @@
"""Canonical error contract for the /openapi/v1 surface.
``ErrorBody`` is the only wire shape an /openapi/v1 endpoint may emit for a
non-2xx response (RFC 8628 device-flow responses excepted — that shape is
mandated by the OAuth spec)::
code str semantic error code (OpenApiErrorCode member)
message str human-readable summary
status int HTTP status, duplicated in the body
hint str | None actionable next step for the caller
details list[ErrorDetail] per-field validation breakdown {type, loc, msg}
``OpenApiErrorFormatter`` is injected into ``ExternalApi`` so every
error-handler path funnels through one builder, and it also rewrites
``e.data`` because flask-restx ``Api.handle_error`` lets a pre-existing
``e.data`` override the registered handler's return value.
The transport-generic enum members, ``_CODE_BY_STATUS`` and the
``OpenApiError``/``OpenApiErrorFormatter`` bases are openapi-only today;
promote them to ``libs`` if a second surface adopts ``ErrorBody``.
"""
import logging
from enum import StrEnum
from typing import Any
from pydantic import BaseModel
from werkzeug.exceptions import HTTPException
from libs.external_api import http_status_message
class OpenApiErrorCode(StrEnum):
# transport-generic (resolved from HTTP status for plain werkzeug raises)
BAD_REQUEST = "bad_request"
UNAUTHORIZED = "unauthorized"
FORBIDDEN = "forbidden"
NOT_FOUND = "not_found"
METHOD_NOT_ALLOWED = "method_not_allowed"
NOT_ACCEPTABLE = "not_acceptable"
CONFLICT = "conflict"
REQUEST_TOO_LARGE = "request_entity_too_large"
UNSUPPORTED_MEDIA_TYPE = "unsupported_media_type"
INVALID_PARAM = "invalid_param"
TOO_MANY_REQUESTS = "too_many_requests"
INTERNAL_ERROR = "internal_server_error"
BAD_GATEWAY = "bad_gateway"
UNKNOWN = "unknown"
# domain codes (must match the error_code attribute of the exception
# classes raised on the openapi surface)
APP_UNAVAILABLE = "app_unavailable"
CONVERSATION_COMPLETED = "conversation_completed"
PROVIDER_NOT_INITIALIZE = "provider_not_initialize"
PROVIDER_QUOTA_EXCEEDED = "provider_quota_exceeded"
MODEL_NOT_SUPPORTED = "model_currently_not_support"
COMPLETION_REQUEST_ERROR = "completion_request_error"
RATE_LIMIT_ERROR = "rate_limit_error"
FILE_TOO_LARGE = "file_too_large"
UNSUPPORTED_FILE_TYPE = "unsupported_file_type"
NO_FILE_UPLOADED = "no_file_uploaded"
TOO_MANY_FILES = "too_many_files"
FILENAME_NOT_EXISTS = "filename_not_exists"
FILE_EXTENSION_BLOCKED = "file_extension_blocked"
MEMBER_LIMIT_EXCEEDED = "member_limit_exceeded"
MEMBER_LICENSE_EXCEEDED = "member_license_exceeded"
class ErrorDetail(BaseModel):
type: str
loc: list[str | int] = []
msg: str
class ErrorBody(BaseModel):
"""Canonical non-2xx body. ``code`` is typed ``str`` (not the enum) so the
generated client schema stays an open enum — old CLIs keep parsing when a
future server adds a code. Formatter tests pin emitted values to the enum."""
code: str
message: str
status: int
hint: str | None = None
details: list[ErrorDetail] | None = None
_CODE_BY_STATUS: dict[int, OpenApiErrorCode] = {
400: OpenApiErrorCode.BAD_REQUEST,
401: OpenApiErrorCode.UNAUTHORIZED,
403: OpenApiErrorCode.FORBIDDEN,
404: OpenApiErrorCode.NOT_FOUND,
405: OpenApiErrorCode.METHOD_NOT_ALLOWED,
406: OpenApiErrorCode.NOT_ACCEPTABLE,
409: OpenApiErrorCode.CONFLICT,
413: OpenApiErrorCode.REQUEST_TOO_LARGE,
415: OpenApiErrorCode.UNSUPPORTED_MEDIA_TYPE,
422: OpenApiErrorCode.INVALID_PARAM,
429: OpenApiErrorCode.TOO_MANY_REQUESTS,
500: OpenApiErrorCode.INTERNAL_ERROR,
502: OpenApiErrorCode.BAD_GATEWAY,
}
_GENERIC_500_MESSAGE = "Internal Server Error"
logger = logging.getLogger(__name__)
class OpenApiError(HTTPException):
"""Dedicated throwable for the /openapi/v1 surface.
A subclass declares ``code`` (HTTP status), ``error_code`` and
``description`` exactly once; call sites just ``raise SomeError()`` —
no per-site dict building, no duplicated message constants. The
formatter emits all three (plus optional ``hint``/``details``) verbatim.
"""
code = 400
error_code: OpenApiErrorCode = OpenApiErrorCode.UNKNOWN
hint: str | None = None
def __init__(
self,
message: str | None = None,
*,
hint: str | None = None,
details: list[ErrorDetail] | None = None,
) -> None:
super().__init__(description=message)
if hint is not None:
self.hint = hint
self.details = details
class OpenApiErrorFormatter:
"""Builds the canonical ErrorBody from whatever the shared handlers computed.
Resolution order for ``code``: explicit ``error_code`` class attribute
(BaseHTTPException subclasses and OpenApiError subclasses) → HTTP status
map → ``unknown``. Class-name-derived codes from the shared handler are
deliberately ignored — they are not a stable contract.
"""
def finalize(self, e: Exception, data: dict[str, Any], status_code: int) -> dict[str, Any]:
exc_data = getattr(e, "data", None)
merged: dict[str, Any] = {**data, **exc_data} if isinstance(exc_data, dict) else dict(data)
# finalize runs inside the framework error handler: raising here would
# replace the response with an unformatted 500, so fall back instead
try:
body = ErrorBody(
code=self._resolve_code(e, status_code),
message=self._resolve_message(merged, status_code),
status=status_code,
hint=self._resolve_hint(e),
details=self._extract_details(e, merged),
)
wire = body.model_dump(mode="json", exclude_none=True)
except Exception:
logger.exception("error-body build failed; emitting fallback body")
wire = {
"code": str(_CODE_BY_STATUS.get(status_code, OpenApiErrorCode.UNKNOWN)),
"message": http_status_message(status_code) or "request failed",
"status": status_code,
}
# flask-restx Api.handle_error does `data = getattr(e, "data", default_data)`
# AFTER our handler returns, so a pre-existing e.data (flask_restx.abort,
# BaseHTTPException) would override the canonical body. Rewrite it.
try:
e.data = wire # type: ignore[attr-defined]
except AttributeError:
pass
return wire
def _resolve_code(self, e: Exception, status_code: int) -> str:
explicit = getattr(type(e), "error_code", None)
if isinstance(explicit, (OpenApiErrorCode, str)) and str(explicit) != "unknown":
return str(explicit)
return str(_CODE_BY_STATUS.get(status_code, OpenApiErrorCode.UNKNOWN))
def _resolve_message(self, merged: dict[str, Any], status_code: int) -> str:
if status_code >= 500:
return _GENERIC_500_MESSAGE
message = merged.get("message")
if isinstance(message, str) and message:
return message
return http_status_message(status_code) or "request failed"
def _resolve_hint(self, e: Exception) -> str | None:
hint = getattr(e, "hint", None)
return hint if isinstance(hint, str) and hint else None
def _extract_details(self, e: Exception, merged: dict[str, Any]) -> list[ErrorDetail] | None:
explicit = getattr(e, "details", None)
if isinstance(explicit, list) and explicit and all(isinstance(d, ErrorDetail) for d in explicit):
return explicit
# an already-canonical body (e.g. e.data rewritten by a prior finalize)
# carries "details"; re-validate so finalize stays idempotent
canonical = merged.get("details")
if isinstance(canonical, list) and canonical and all(isinstance(d, dict) for d in canonical):
return [ErrorDetail.model_validate(d) for d in canonical]
errors = merged.get("errors")
if isinstance(errors, list) and errors:
details = [
ErrorDetail(
type=str(item.get("type", "invalid")),
loc=[part for part in item.get("loc", []) if self._is_loc_part(part)],
msg=str(item.get("msg", "")),
)
for item in errors
if isinstance(item, dict)
]
return details or None
params = merged.get("params")
if isinstance(params, str) and params:
return [ErrorDetail(type="invalid", loc=[params], msg=str(merged.get("message", "")))]
return None
@staticmethod
def _is_loc_part(part: Any) -> bool:
# bool is an int subclass but is not a valid path segment
return isinstance(part, (str, int)) and not isinstance(part, bool)
class FilenameNotExists(OpenApiError): # noqa: N818
code = 400
error_code = OpenApiErrorCode.FILENAME_NOT_EXISTS
description = "The specified filename does not exist."
class MemberLimitExceeded(OpenApiError): # noqa: N818
code = 403
error_code = OpenApiErrorCode.MEMBER_LIMIT_EXCEEDED
description = "Subscription member limit reached."
hint = "Upgrade your plan to invite more members or remove an existing member first."
class MemberLicenseExceeded(OpenApiError): # noqa: N818
code = 403
error_code = OpenApiErrorCode.MEMBER_LICENSE_EXCEEDED
description = "Workspace member license capacity reached."
hint = "Contact your workspace administrator to expand the license seat count."

View File

@ -10,6 +10,7 @@ from werkzeug.exceptions import BadRequest
import services
from controllers.common.errors import (
BlockedFileExtensionError,
FilenameNotExistsError,
FileTooLargeError,
NoFileUploadedError,
TooManyFilesError,
@ -17,7 +18,6 @@ from controllers.common.errors import (
)
from controllers.openapi import openapi_ns
from controllers.openapi._contract import returns
from controllers.openapi._errors import FilenameNotExists
from controllers.openapi.auth.composition import auth_router
from controllers.openapi.auth.data import AuthData
from extensions.ext_database import db
@ -52,7 +52,7 @@ class AppFileUploadApi(Resource):
if not file.mimetype:
raise UnsupportedFileTypeError()
if not file.filename:
raise FilenameNotExists()
raise FilenameNotExistsError()
try:
upload_file = FileService(db.engine).upload_file(

View File

@ -14,13 +14,13 @@ from __future__ import annotations
from itertools import starmap
from urllib import parse
from flask import jsonify, make_response
from flask_restx import Resource
from werkzeug.exceptions import BadRequest, NotFound
from werkzeug.exceptions import BadRequest, Forbidden, NotFound
from configs import dify_config
from controllers.openapi import openapi_ns
from controllers.openapi._contract import accepts, returns
from controllers.openapi._errors import MemberLicenseExceeded, MemberLimitExceeded
from controllers.openapi._models import (
MemberActionResponse,
MemberInvitePayload,
@ -77,16 +77,34 @@ def _load_account(account_id: object) -> Account:
return account
def _quota_error(*, code: str, message: str, hint: str) -> Forbidden:
err = Forbidden(message)
err.response = make_response(
jsonify({"code": code, "message": message, "hint": hint}),
403,
)
return err
def _check_member_invite_quota(tenant_id: str) -> None:
features = FeatureService.get_features(tenant_id)
if features.billing.enabled:
members = features.members
if 0 < members.limit <= members.size:
raise MemberLimitExceeded()
raise _quota_error(
code="members.limit_exceeded",
message="Subscription member limit reached.",
hint="Upgrade your plan to invite more members or remove an existing member first.",
)
if features.workspace_members.enabled and not features.workspace_members.is_available(1):
raise MemberLicenseExceeded()
if features.workspace_members.enabled:
if not features.workspace_members.is_available(1):
raise _quota_error(
code="workspace_members.license_exceeded",
message="Workspace member license capacity reached.",
hint="Contact your workspace administrator to expand the license seat count.",
)
@openapi_ns.route("/workspaces")

View File

@ -35,7 +35,6 @@ from core.workflow.nodes.agent_v2.plugin_tools_builder import (
from core.workflow.nodes.agent_v2.runtime_request_builder import build_shell_layer_config
from models.agent_config_entities import AgentSoulConfig
from models.provider_ids import ModelProviderID
from services.agent.prompt_mentions import build_soul_mention_resolver, expand_prompt_mentions
class AgentAppRuntimeRequestBuildError(ValueError):
@ -136,12 +135,7 @@ class AgentAppRuntimeRequestBuilder:
invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value),
agent_mode="agent_app",
),
# ENG-616: expand slash-menu mention tokens to canonical names so
# no frontend-internal {{#…#}} marker ever reaches the model.
agent_soul_prompt=expand_prompt_mentions(
agent_soul.prompt.system_prompt, build_soul_mention_resolver(agent_soul)
).strip()
or None,
agent_soul_prompt=agent_soul.prompt.system_prompt or None,
user_prompt=context.user_query,
tools=tools_layer,
include_shell=dify_config.AGENT_SHELL_ENABLED,

View File

@ -1,86 +0,0 @@
"""Draft-workflow graph topology helper, shared by Agent v2 publish validation
and the agent-composer candidates endpoint (ENG-615).
Extracted from ``core/workflow/nodes/agent_v2/validators.py`` so both call sites
parse the same ``Workflow.graph`` JSON shape (``nodes`` with string ids,
``edges`` with ``source``/``target``).
"""
from __future__ import annotations
from collections import defaultdict, deque
from collections.abc import Mapping, Sequence
from typing import Any
class WorkflowGraphTopology:
def __init__(self, *, node_ids: set[str], incoming: Mapping[str, Sequence[str]]) -> None:
self._node_ids = node_ids
self._incoming = incoming
@classmethod
def from_graph(cls, graph: Mapping[str, Any]) -> WorkflowGraphTopology:
node_ids = cls._node_ids_from_graph(graph)
incoming: dict[str, list[str]] = defaultdict(list)
edges = graph.get("edges")
if isinstance(edges, list):
for edge in edges:
if not isinstance(edge, Mapping):
continue
source = edge.get("source")
target = edge.get("target")
if isinstance(source, str) and isinstance(target, str):
incoming[target].append(source)
return cls(node_ids=node_ids, incoming=incoming)
def has_node(self, node_id: str) -> bool:
return node_id in self._node_ids
def is_upstream(self, *, source_node_id: str, target_node_id: str) -> bool:
if source_node_id == target_node_id:
return False
visited: set[str] = set()
queue: deque[str] = deque(self._incoming.get(target_node_id, ()))
while queue:
candidate = queue.popleft()
if candidate == source_node_id:
return True
if candidate in visited:
continue
visited.add(candidate)
queue.extend(self._incoming.get(candidate, ()))
return False
def upstream_node_ids(self, target_node_id: str) -> set[str]:
"""All graph nodes reachable upstream of ``target_node_id`` (excluding it).
Edges may reference ids missing from ``nodes`` (half-deleted graphs);
only real nodes are returned.
"""
visited: set[str] = set()
queue: deque[str] = deque(self._incoming.get(target_node_id, ()))
while queue:
candidate = queue.popleft()
if candidate in visited:
continue
visited.add(candidate)
queue.extend(self._incoming.get(candidate, ()))
visited.discard(target_node_id)
return visited & self._node_ids
@staticmethod
def _node_ids_from_graph(graph: Mapping[str, Any]) -> set[str]:
node_ids: set[str] = set()
nodes = graph.get("nodes")
if not isinstance(nodes, list):
return node_ids
for node in nodes:
if not isinstance(node, Mapping):
continue
node_id = node.get("id")
if isinstance(node_id, str):
node_ids.add(node_id)
return node_ids
__all__ = ["WorkflowGraphTopology"]

View File

@ -45,11 +45,6 @@ from models.agent_config_entities import (
effective_declared_outputs as _effective_declared_outputs,
)
from models.provider_ids import ModelProviderID
from services.agent.prompt_mentions import (
build_node_job_mention_resolver,
build_soul_mention_resolver,
expand_prompt_mentions,
)
from .output_failure_orchestrator import retry_idempotency_key
from .plugin_tools_builder import WorkflowAgentPluginToolsBuilder, WorkflowAgentPluginToolsBuildError
@ -134,16 +129,7 @@ class WorkflowAgentRuntimeRequestBuilder:
metadata = self._build_metadata(context, agent_soul, node_job)
workflow_context_prompt = self._build_workflow_context_prompt(context, node_job)
# ENG-616: expand slash-menu mention tokens into model-readable names.
# node_output mentions expand to their reference name only — the value
# stays in the Workflow context block (user_prompt) below.
workflow_job_prompt = (
expand_prompt_mentions(node_job.workflow_prompt, build_node_job_mention_resolver(node_job)).strip()
or "Run this workflow Agent Node for the current run."
)
soul_prompt = expand_prompt_mentions(
agent_soul.prompt.system_prompt, build_soul_mention_resolver(agent_soul)
).strip()
workflow_job_prompt = node_job.workflow_prompt.strip() or "Run this workflow Agent Node for the current run."
user_prompt = workflow_context_prompt.strip() or "Use the current workflow context."
credentials = self._credentials_provider.fetch(agent_soul.model.model_provider, agent_soul.model.model)
try:
@ -201,7 +187,7 @@ class WorkflowAgentRuntimeRequestBuilder:
agent_mode=self._agent_backend_agent_mode(context.dify_context.invoke_from),
invoke_from=cast(DifyExecutionContextInvokeFrom, context.dify_context.invoke_from.value),
),
agent_soul_prompt=soul_prompt or None,
agent_soul_prompt=agent_soul.prompt.system_prompt or None,
workflow_node_job_prompt=workflow_job_prompt,
user_prompt=user_prompt,
output=self._build_output_config(node_job.declared_outputs),

View File

@ -1,12 +1,12 @@
from __future__ import annotations
from collections.abc import Iterator, Mapping
from collections import defaultdict, deque
from collections.abc import Iterator, Mapping, Sequence
from typing import Any
from sqlalchemy import select
from sqlalchemy.orm import Session
from core.workflow.graph_topology import WorkflowGraphTopology
from graphon.enums import BuiltinNodeTypes
from models.agent import Agent, AgentConfigSnapshot, AgentStatus, WorkflowAgentNodeBinding
from models.agent_config_entities import (
@ -523,6 +523,54 @@ class WorkflowAgentNodeValidator:
)
# Extracted to core/workflow/graph_topology.py (shared with the agent-composer
# candidates endpoint, ENG-615); kept as a private alias for existing call sites.
_WorkflowGraphTopology = WorkflowGraphTopology
class _WorkflowGraphTopology:
def __init__(self, *, node_ids: set[str], incoming: Mapping[str, Sequence[str]]) -> None:
self._node_ids = node_ids
self._incoming = incoming
@classmethod
def from_graph(cls, graph: Mapping[str, Any]) -> _WorkflowGraphTopology:
node_ids = cls._node_ids_from_graph(graph)
incoming: dict[str, list[str]] = defaultdict(list)
edges = graph.get("edges")
if isinstance(edges, list):
for edge in edges:
if not isinstance(edge, Mapping):
continue
source = edge.get("source")
target = edge.get("target")
if isinstance(source, str) and isinstance(target, str):
incoming[target].append(source)
return cls(node_ids=node_ids, incoming=incoming)
def has_node(self, node_id: str) -> bool:
return node_id in self._node_ids
def is_upstream(self, *, source_node_id: str, target_node_id: str) -> bool:
if source_node_id == target_node_id:
return False
visited: set[str] = set()
queue: deque[str] = deque(self._incoming.get(target_node_id, ()))
while queue:
candidate = queue.popleft()
if candidate == source_node_id:
return True
if candidate in visited:
continue
visited.add(candidate)
queue.extend(self._incoming.get(candidate, ()))
return False
@staticmethod
def _node_ids_from_graph(graph: Mapping[str, Any]) -> set[str]:
node_ids: set[str] = set()
nodes = graph.get("nodes")
if not isinstance(nodes, list):
return node_ids
for node in nodes:
if not isinstance(node, Mapping):
continue
node_id = node.get("id")
if isinstance(node_id, str):
node_ids.add(node_id)
return node_ids

View File

@ -1,4 +1,4 @@
from typing import Annotated, Literal
from typing import Literal
from pydantic import Field
@ -14,7 +14,6 @@ from models.agent import (
)
from models.agent_config_entities import (
AgentCliToolConfig,
AgentFileRefConfig,
AgentHumanContactConfig,
AgentKnowledgeDatasetConfig,
AgentSkillRefConfig,
@ -155,7 +154,6 @@ class WorkflowAgentComposerResponse(ResponseModel):
effective_declared_outputs: list[DeclaredOutputConfig] = Field(default_factory=list)
save_options: list[ComposerSaveStrategy]
impact_summary: AgentComposerImpactResponse | None = None
validation: "ComposerValidationFindingsResponse | None" = None
app_id: str | None = None
workflow_id: str | None = None
node_id: str | None = None
@ -167,32 +165,11 @@ class AgentAppComposerResponse(ResponseModel):
active_config_snapshot: AgentConfigSnapshotSummaryResponse
agent_soul: AgentSoulConfig
save_options: list[ComposerSaveStrategy]
validation: "ComposerValidationFindingsResponse | None" = None
class ComposerValidationWarningResponse(ResponseModel):
code: str
surface: str | None = None
kind: str | None = None
id: str | None = None
message: str | None = None
class ComposerKnowledgePlaceholderResponse(ResponseModel):
id: str
placeholder_name: str
class ComposerValidationFindingsResponse(ResponseModel):
warnings: list[ComposerValidationWarningResponse] = Field(default_factory=list)
knowledge_retrieval_placeholder: list[ComposerKnowledgePlaceholderResponse] = Field(default_factory=list)
class AgentComposerValidateResponse(ResponseModel):
result: Literal["success"]
errors: list[str] = Field(default_factory=list)
warnings: list[ComposerValidationWarningResponse] = Field(default_factory=list)
knowledge_retrieval_placeholder: list[ComposerKnowledgePlaceholderResponse] = Field(default_factory=list)
class AgentComposerDifyToolCandidateResponse(ResponseModel):
@ -204,20 +181,6 @@ class AgentComposerDifyToolCandidateResponse(ResponseModel):
plugin_id: str | None = None
class AgentComposerSkillCandidateResponse(AgentSkillRefConfig):
kind: Literal["skill"] = "skill"
class AgentComposerFileCandidateResponse(AgentFileRefConfig):
kind: Literal["file"] = "file"
AgentComposerSkillFileCandidateResponse = Annotated[
AgentComposerSkillCandidateResponse | AgentComposerFileCandidateResponse,
Field(discriminator="kind"),
]
class AgentComposerNodeJobCandidatesResponse(ResponseModel):
previous_node_outputs: list[WorkflowPreviousNodeOutputRef] = Field(default_factory=list)
declare_output_types: list[DeclaredOutputType] = Field(default_factory=list)
@ -225,7 +188,7 @@ class AgentComposerNodeJobCandidatesResponse(ResponseModel):
class AgentComposerSoulCandidatesResponse(ResponseModel):
skills_files: list[AgentComposerSkillFileCandidateResponse] = Field(default_factory=list)
skills_files: list[AgentSkillRefConfig] = Field(default_factory=list)
dify_tools: list[AgentComposerDifyToolCandidateResponse] = Field(default_factory=list)
cli_tools: list[AgentCliToolConfig] = Field(default_factory=list)
knowledge_datasets: list[AgentKnowledgeDatasetConfig] = Field(default_factory=list)
@ -241,4 +204,3 @@ class AgentComposerCandidatesResponse(ResponseModel):
default_factory=AgentComposerSoulCandidatesResponse
)
capabilities: ComposerCandidateCapabilities = Field(default_factory=ComposerCandidateCapabilities)
truncated: bool = False

View File

@ -1,8 +1,8 @@
import re
from collections.abc import Mapping
from typing import Any, Protocol, override
from typing import Any
from flask import Blueprint, Flask, current_app, got_request_exception, request
from flask import Blueprint, Flask, current_app, got_request_exception
from flask_restx import Api
from werkzeug.exceptions import HTTPException
from werkzeug.http import HTTP_STATUS_CODES
@ -17,24 +17,11 @@ def http_status_message(code):
return HTTP_STATUS_CODES.get(code, "")
class ErrorBodyFormatter(Protocol):
"""Last-touch hook over an error body before it goes on the wire."""
def finalize(self, e: Exception, data: dict[str, Any], status_code: int) -> dict[str, Any]: ...
def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatter | None = None):
def _finalize(e: Exception, data: dict[str, Any], status_code: int) -> dict[str, Any]:
if body_formatter is None:
return data
return body_formatter.finalize(e, data, status_code)
def register_external_error_handlers(api: Api):
def handle_http_exception(e: HTTPException):
got_request_exception.send(current_app, exception=e)
# If Werkzeug already prepared a Response, just use it. This bypasses
# body_formatter entirely — surfaces with a formatter must not raise
# exceptions carrying a pre-built response.
# If Werkzeug already prepared a Response, just use it.
if e.response is not None:
return e.response
@ -58,7 +45,7 @@ def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatte
# Payload per status
if status_code == 406 and api.default_mediatype is None:
data = {"code": "not_acceptable", "message": default_data["message"], "status": status_code}
return _finalize(e, data, status_code), status_code, headers
return data, status_code, headers
elif status_code == 400:
msg = default_data["message"]
if isinstance(msg, Mapping) and msg:
@ -73,7 +60,7 @@ def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatte
else:
data = {**default_data}
data.setdefault("code", "unknown")
return _finalize(e, data, status_code), status_code, headers
return data, status_code, headers
else:
data = {**default_data}
data.setdefault("code", "unknown")
@ -85,20 +72,20 @@ def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatte
if error_code == "unauthorized_and_force_logout":
# Add Set-Cookie headers to clear auth cookies
headers["Set-Cookie"] = build_force_logout_cookie_headers()
return _finalize(e, data, status_code), status_code, headers
return data, status_code, headers
def handle_value_error(e: ValueError):
got_request_exception.send(current_app, exception=e)
current_app.logger.exception("value_error in request handler")
status_code = 400
data = {"code": "invalid_param", "message": str(e), "status": status_code}
return _finalize(e, data, status_code), status_code
return data, status_code
def handle_quota_exceeded(e: AppInvokeQuotaExceededError):
got_request_exception.send(current_app, exception=e)
status_code = 429
data = {"code": "too_many_requests", "message": str(e), "status": status_code}
return _finalize(e, data, status_code), status_code
return data, status_code
def handle_general_exception(e: Exception):
got_request_exception.send(current_app, exception=e)
@ -116,7 +103,7 @@ def register_external_error_handlers(api: Api, body_formatter: ErrorBodyFormatte
# Note: Exception logging is handled by Flask/Flask-RESTX framework automatically
# Explicit log_exception call removed to avoid duplicate log entries
return _finalize(e, data, status_code), status_code
return data, status_code
api.errorhandler(HTTPException)(handle_http_exception)
api.errorhandler(ValueError)(handle_value_error)
@ -134,46 +121,14 @@ class ExternalApi(Api):
}
}
def __init__(self, app: Blueprint | Flask, *args, error_body_formatter: ErrorBodyFormatter | None = None, **kwargs):
self._error_body_formatter = error_body_formatter
def __init__(self, app: Blueprint | Flask, *args, **kwargs):
patch_swagger_for_inline_nested_dicts()
kwargs.setdefault("authorizations", self._authorizations)
kwargs.setdefault("security", "Bearer")
kwargs["add_specs"] = dify_config.SWAGGER_UI_ENABLED
kwargs["doc"] = dify_config.SWAGGER_UI_PATH if dify_config.SWAGGER_UI_ENABLED else False
if error_body_formatter is not None:
kwargs.setdefault("catch_all_404s", True)
# the overrides below patch private flask-restx methods; fail at
# startup (not at the first 404) if an upgrade removes them
for private_hook in ("_should_use_fr_error_handler", "_help_on_404"):
if not callable(getattr(Api, private_hook, None)):
raise RuntimeError(f"flask-restx no longer exposes {private_hook}; update ExternalApi overrides")
# manual separate call on construction and init_app to ensure configs in kwargs effective
super().__init__(app=None, *args, **kwargs)
self.init_app(app, **kwargs)
register_external_error_handlers(self, body_formatter=error_body_formatter)
@override
def _should_use_fr_error_handler(self):
# catch_all_404s makes flask-restx claim NotFound for ANY app path
# (it wraps the app-level handle_exception), so scope the claim to
# this blueprint's url prefix; other surfaces keep their own 404s.
if self._error_body_formatter is not None and not self._request_under_own_prefix():
return False
return super()._should_use_fr_error_handler()
def _request_under_own_prefix(self) -> bool:
prefix = self.blueprint.url_prefix if self.blueprint is not None else None
if not prefix:
return True
return request.path == prefix or request.path.startswith(prefix.rstrip("/") + "/")
@override
def _help_on_404(self, message: str | None = None) -> str | None:
# flask-restx appends route suggestions post-handler; with a canonical
# formatter installed, that would corrupt the contract and enumerate
# routes to unauthenticated callers.
if self._error_body_formatter is not None:
return message
return super()._help_on_404(message)
register_external_error_handlers(self)

View File

@ -11808,7 +11808,6 @@ Get banner list
| agent | [AgentComposerAgentResponse](#agentcomposeragentresponse) | | Yes |
| agent_soul | [AgentSoulConfig](#agentsoulconfig) | | Yes |
| save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes |
| validation | [ComposerValidationFindingsResponse](#composervalidationfindingsresponse) | | No |
| variant | string | | Yes |
#### AgentAppFeaturesPayload
@ -11904,7 +11903,6 @@ Risk marker for CLI tool bootstrap commands.
| allowed_node_job_candidates | [AgentComposerNodeJobCandidatesResponse](#agentcomposernodejobcandidatesresponse) | | No |
| allowed_soul_candidates | [AgentComposerSoulCandidatesResponse](#agentcomposersoulcandidatesresponse) | | No |
| capabilities | [ComposerCandidateCapabilities](#composercandidatecapabilities) | | No |
| truncated | boolean | | No |
| variant | [ComposerVariant](#composervariant) | | Yes |
#### AgentComposerDifyToolCandidateResponse
@ -11918,22 +11916,6 @@ Risk marker for CLI tool bootstrap commands.
| provider | string | | No |
| provider_id | string | | No |
#### AgentComposerFileCandidateResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| file_id | string | | No |
| id | string | | No |
| kind | string | | No |
| name | string | | No |
| reference | string | | No |
| remote_url | string | | No |
| tenant_id | string | | No |
| transfer_method | string | | No |
| type | string | | No |
| upload_file_id | string | | No |
| url | string | | No |
#### AgentComposerImpactBindingResponse
| Name | Type | Description | Required |
@ -11958,17 +11940,6 @@ Risk marker for CLI tool bootstrap commands.
| human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No |
| previous_node_outputs | [ [WorkflowPreviousNodeOutputRef](#workflowpreviousnodeoutputref) ] | | No |
#### AgentComposerSkillCandidateResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| description | string | | No |
| file_id | string | | No |
| id | string | | No |
| kind | string | | No |
| name | string | | No |
| path | string | | No |
#### AgentComposerSoulCandidatesResponse
| Name | Type | Description | Required |
@ -11977,7 +11948,7 @@ Risk marker for CLI tool bootstrap commands.
| dify_tools | [ [AgentComposerDifyToolCandidateResponse](#agentcomposerdifytoolcandidateresponse) ] | | No |
| human_contacts | [ [AgentHumanContactConfig](#agenthumancontactconfig) ] | | No |
| knowledge_datasets | [ [AgentKnowledgeDatasetConfig](#agentknowledgedatasetconfig) ] | | No |
| skills_files | [ ] | | No |
| skills_files | [ [AgentSkillRefConfig](#agentskillrefconfig) ] | | No |
#### AgentComposerSoulLockResponse
@ -11992,9 +11963,7 @@ Risk marker for CLI tool bootstrap commands.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| errors | [ string ] | | No |
| knowledge_retrieval_placeholder | [ [ComposerKnowledgePlaceholderResponse](#composerknowledgeplaceholderresponse) ] | | No |
| result | string | | Yes |
| warnings | [ [ComposerValidationWarningResponse](#composervalidationwarningresponse) ] | | No |
#### AgentConfigRevisionOperation
@ -13317,13 +13286,6 @@ Button styles for user actions.
| ---- | ---- | ----------- | -------- |
| human_roster_available | boolean | | No |
#### ComposerKnowledgePlaceholderResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| id | string | | Yes |
| placeholder_name | string | | Yes |
#### ComposerSavePayload
| Name | Type | Description | Required |
@ -13352,23 +13314,6 @@ Button styles for user actions.
| locked | boolean | | No |
| unlocked_from_version_id | string | | No |
#### ComposerValidationFindingsResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| knowledge_retrieval_placeholder | [ [ComposerKnowledgePlaceholderResponse](#composerknowledgeplaceholderresponse) ] | | No |
| warnings | [ [ComposerValidationWarningResponse](#composervalidationwarningresponse) ] | | No |
#### ComposerValidationWarningResponse
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| code | string | | Yes |
| id | string | | No |
| kind | string | | No |
| message | string | | No |
| surface | string | | No |
#### ComposerVariant
| Name | Type | Description | Required |
@ -17626,7 +17571,6 @@ How a workflow node is bound to an Agent.
| node_job | [WorkflowNodeJobConfig](#workflownodejobconfig) | | Yes |
| save_options | [ [ComposerSaveStrategy](#composersavestrategy) ] | | Yes |
| soul_lock | [AgentComposerSoulLockResponse](#agentcomposersoullockresponse) | | Yes |
| validation | [ComposerValidationFindingsResponse](#composervalidationfindingsresponse) | | No |
| variant | string | | Yes |
| workflow_id | string | | No |

View File

@ -24,7 +24,6 @@ User-scoped operations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Health check | [HealthResponse](#healthresponse) |
| default | Error | [ErrorBody](#errorbody) |
### /_version
@ -34,7 +33,6 @@ User-scoped operations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Server version | [ServerVersionResponse](#serverversionresponse) |
| default | Error | [ErrorBody](#errorbody) |
### /account
@ -44,7 +42,6 @@ User-scoped operations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Account info | [AccountResponse](#accountresponse) |
| default | Error | [ErrorBody](#errorbody) |
### /account/sessions
@ -61,8 +58,6 @@ User-scoped operations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Session list | [SessionListResponse](#sessionlistresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
### /account/sessions/self
@ -72,7 +67,6 @@ User-scoped operations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Session revoked | [RevokeResponse](#revokeresponse) |
| default | Error | [ErrorBody](#errorbody) |
### /account/sessions/{session_id}
@ -88,7 +82,6 @@ User-scoped operations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Session revoked | [RevokeResponse](#revokeresponse) |
| default | Error | [ErrorBody](#errorbody) |
### /apps
@ -109,8 +102,6 @@ User-scoped operations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | App list | [AppListResponse](#applistresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
### /apps/{app_id}/check-dependencies
@ -126,7 +117,6 @@ User-scoped operations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Dependencies checked | [CheckDependenciesResult](#checkdependenciesresult) |
| default | Error | [ErrorBody](#errorbody) |
### /apps/{app_id}/describe
@ -143,8 +133,6 @@ User-scoped operations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | App description | [AppDescribeResponse](#appdescriberesponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
### /apps/{app_id}/export
@ -162,8 +150,6 @@ User-scoped operations
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Export successful | [AppDslExportResponse](#appdslexportresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
### /apps/{app_id}/files/upload
@ -187,7 +173,6 @@ Upload a file to use as an input variable when running the app
| 401 | Unauthorized — invalid or expired bearer token | |
| 413 | File too large | |
| 415 | Unsupported file type or blocked extension | |
| default | Error | [ErrorBody](#errorbody) |
### /apps/{app_id}/form/human_input/{form_token}
@ -219,8 +204,6 @@ Upload a file to use as an input variable when running the app
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Form submitted | [FormSubmitResponse](#formsubmitresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
### /apps/{app_id}/run
@ -234,10 +217,9 @@ Upload a file to use as an input variable when running the app
##### Responses
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Run result (SSE stream) | |
| 422 | Validation error | [ErrorBody](#errorbody) |
| Code | Description |
| ---- | ----------- |
| 200 | Run result (SSE stream) |
### /apps/{app_id}/tasks/{task_id}/events
@ -270,7 +252,6 @@ Upload a file to use as an input variable when running the app
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Task stopped | [TaskStopResponse](#taskstopresponse) |
| default | Error | [ErrorBody](#errorbody) |
### /oauth/device/approve
@ -364,8 +345,6 @@ Upload a file to use as an input variable when running the app
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Permitted external apps list | [PermittedExternalAppsListResponse](#permittedexternalappslistresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
### /workspaces
@ -375,7 +354,6 @@ Upload a file to use as an input variable when running the app
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workspace list | [WorkspaceListResponse](#workspacelistresponse) |
| default | Error | [ErrorBody](#errorbody) |
### /workspaces/{workspace_id}
@ -391,7 +369,6 @@ Upload a file to use as an input variable when running the app
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workspace detail | [WorkspaceDetailResponse](#workspacedetailresponse) |
| default | Error | [ErrorBody](#errorbody) |
### /workspaces/{workspace_id}/apps/imports
@ -410,8 +387,6 @@ Upload a file to use as an input variable when running the app
| 200 | Import completed | [Import](#import) |
| 202 | Import pending confirmation | [Import](#import) |
| 400 | Import failed | [Import](#import) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
### /workspaces/{workspace_id}/apps/imports/{import_id}/confirm
@ -429,7 +404,6 @@ Upload a file to use as an input variable when running the app
| ---- | ----------- | ------ |
| 200 | Import confirmed | [Import](#import) |
| 400 | Import failed | [Import](#import) |
| default | Error | [ErrorBody](#errorbody) |
### /workspaces/{workspace_id}/members
@ -447,8 +421,6 @@ Upload a file to use as an input variable when running the app
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Member list | [MemberListResponse](#memberlistresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
#### POST
##### Parameters
@ -463,8 +435,6 @@ Upload a file to use as an input variable when running the app
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 201 | Member invited | [MemberInviteResponse](#memberinviteresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
### /workspaces/{workspace_id}/members/{member_id}
@ -481,7 +451,6 @@ Upload a file to use as an input variable when running the app
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Member removed | [MemberActionResponse](#memberactionresponse) |
| default | Error | [ErrorBody](#errorbody) |
### /workspaces/{workspace_id}/members/{member_id}/role
@ -499,8 +468,6 @@ Upload a file to use as an input variable when running the app
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Role updated | [MemberActionResponse](#memberactionresponse) |
| 422 | Validation error | [ErrorBody](#errorbody) |
| default | Error | [ErrorBody](#errorbody) |
### /workspaces/{workspace_id}/switch
@ -516,7 +483,6 @@ Upload a file to use as an input variable when running the app
| Code | Description | Schema |
| ---- | ----------- | ------ |
| 200 | Workspace detail | [WorkspaceDetailResponse](#workspacedetailresponse) |
| default | Error | [ErrorBody](#errorbody) |
---
### Models
@ -727,28 +693,6 @@ mode is a closed enum.
| client_id | string | | Yes |
| device_code | string | | Yes |
#### ErrorBody
Canonical non-2xx body. ``code`` is typed ``str`` (not the enum) so the
generated client schema stays an open enum — old CLIs keep parsing when a
future server adds a code. Formatter tests pin emitted values to the enum.
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| code | string | | Yes |
| details | [ [ErrorDetail](#errordetail) ] | | No |
| hint | string | | No |
| message | string | | Yes |
| status | integer | | Yes |
#### ErrorDetail
| Name | Type | Description | Required |
| ---- | ---- | ----------- | -------- |
| loc | [ ] | | No |
| msg | string | | Yes |
| type | string | | Yes |
#### FileResponse
| Name | Type | Description | Required |

View File

@ -1,210 +0,0 @@
"""Slash-menu candidates assembly (ENG-615).
Pure assembly over injected loaders so the upstream-graph computation and the
per-source mapping are unit-testable without a database. IO wiring (draft
workflow / bindings / draft variables / datasets / workspace tools) lives in
``AgentComposerService.get_*_candidates``.
``previous_node_outputs`` entries are emitted in the stored
``WorkflowPreviousNodeOutputRef`` shape (``selector``/``node_id``/``output``/
``name``) so the frontend can write a selected candidate back into
``node_job.previous_node_output_refs`` verbatim; display extras
(``node_title``/``node_kind``/``value_type``/``inferred``) ride along via the
flexible config schema. Output enumeration follows the Node Output Inspector:
start variables + recorded ``sys.*`` variables are static, Agent v2 nodes use
their binding's declared outputs, and every other node kind is inferred from
the latest draft-run variables (``inferred: true``).
"""
from __future__ import annotations
from collections.abc import Callable, Mapping
from typing import Any
from models.agent_config_entities import (
AgentSoulConfig,
DeclaredOutputConfig,
)
MAX_CANDIDATES_PER_LIST = 200
_SYSTEM_NODE_ID = "sys"
# loader signatures injected by the service layer
DeclaredOutputsLoader = Callable[[str], list[DeclaredOutputConfig] | None]
DraftVariablesLoader = Callable[[str], list[tuple[str, str | None]]]
SystemVariablesLoader = Callable[[], list[tuple[str, str | None]]]
DatasetLookup = Callable[[list[str]], Mapping[str, Any]]
WorkspaceToolsLoader = Callable[[], list[dict[str, Any]]]
def previous_node_output_candidates(
*,
graph: Mapping[str, Any],
node_id: str,
declared_outputs_loader: DeclaredOutputsLoader,
draft_variables_loader: DraftVariablesLoader,
system_variables_loader: SystemVariablesLoader,
) -> tuple[list[dict[str, Any]], bool]:
"""Enumerate upstream node outputs for ``node_id`` as writable ref candidates."""
from core.workflow.graph_topology import WorkflowGraphTopology
topology = WorkflowGraphTopology.from_graph(graph)
upstream = topology.upstream_node_ids(node_id)
entries: list[dict[str, Any]] = []
for name, value_type in system_variables_loader():
entries.append(
_ref_entry(
node_id=_SYSTEM_NODE_ID,
output=name,
node_title="System",
node_kind="system",
value_type=value_type,
inferred=True,
)
)
nodes = graph.get("nodes")
for node in nodes if isinstance(nodes, list) else []:
if not isinstance(node, Mapping):
continue
nid = node.get("id")
if not isinstance(nid, str) or nid not in upstream:
continue
raw_data = node.get("data")
data: Mapping[str, Any] = raw_data if isinstance(raw_data, Mapping) else {}
kind = str(data.get("type") or "unknown")
title = str(data.get("title") or nid)
if kind == "start":
for variable in data.get("variables") or []:
if not isinstance(variable, Mapping):
continue
var_name = variable.get("variable")
if isinstance(var_name, str) and var_name:
entries.append(
_ref_entry(
node_id=nid,
output=var_name,
node_title=title,
node_kind=kind,
value_type=variable.get("type") if isinstance(variable.get("type"), str) else None,
inferred=False,
)
)
continue
declared: list[DeclaredOutputConfig] | None = None
if kind == "agent" and str(data.get("version", "")) == "2":
declared = declared_outputs_loader(nid)
if declared is not None:
for output in declared:
entries.append(
_ref_entry(
node_id=nid,
output=output.name,
node_title=title,
node_kind=kind,
value_type=output.type.value,
inferred=False,
)
)
continue
for var_name, value_type in draft_variables_loader(nid):
entries.append(
_ref_entry(
node_id=nid,
output=var_name,
node_title=title,
node_kind=kind,
value_type=value_type,
inferred=True,
)
)
return _capped(entries)
def soul_candidates(
*,
agent_soul: AgentSoulConfig | None,
dataset_lookup: DatasetLookup,
workspace_tools_loader: WorkspaceToolsLoader,
) -> tuple[dict[str, list[dict[str, Any]]], bool]:
"""Assemble the soul-surface candidate lists (design §3.2)."""
soul = agent_soul or AgentSoulConfig()
truncated = False
skills_files = [{"kind": "skill", **skill.model_dump(exclude_none=True)} for skill in soul.skills_files.skills]
skills_files += [{"kind": "file", **file.model_dump(exclude_none=True)} for file in soul.skills_files.files]
cli_tools = [tool.model_dump(exclude_none=True) for tool in soul.tools.cli_tools if tool.enabled]
dataset_ids = [dataset.id for dataset in soul.knowledge.datasets if dataset.id]
dataset_rows = dataset_lookup(dataset_ids) if dataset_ids else {}
knowledge_datasets: list[dict[str, Any]] = []
for dataset in soul.knowledge.datasets:
if not dataset.id:
continue
row = dataset_rows.get(dataset.id)
knowledge_datasets.append(
{
"id": dataset.id,
"name": (getattr(row, "name", None) or dataset.name or dataset.id),
"description": getattr(row, "description", None) or dataset.description,
"missing": row is None,
}
)
human_contacts = [contact.model_dump(exclude_none=True) for contact in soul.human.contacts]
dify_tools = workspace_tools_loader()
lists = {
"skills_files": skills_files,
"dify_tools": dify_tools,
"cli_tools": cli_tools,
"knowledge_datasets": knowledge_datasets,
"human_contacts": human_contacts,
}
capped: dict[str, list[dict[str, Any]]] = {}
for key, values in lists.items():
clipped, was_clipped = _capped(values)
truncated = truncated or was_clipped
capped[key] = clipped
return capped, truncated
def _ref_entry(
*,
node_id: str,
output: str,
node_title: str,
node_kind: str,
value_type: str | None,
inferred: bool,
) -> dict[str, Any]:
return {
"selector": [node_id, output],
"node_id": node_id,
"output": output,
"name": f"{node_title}/{output}",
"node_title": node_title,
"node_kind": node_kind,
"value_type": value_type,
"inferred": inferred,
}
def _capped(values: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], bool]:
if len(values) > MAX_CANDIDATES_PER_LIST:
return values[:MAX_CANDIDATES_PER_LIST], True
return values, False
__all__ = [
"MAX_CANDIDATES_PER_LIST",
"previous_node_output_candidates",
"soul_candidates",
]

View File

@ -1,4 +1,3 @@
import logging
from typing import Any
from sqlalchemy import func, select
@ -40,8 +39,6 @@ from services.entities.agent_entities import (
# Mirrors Workflow.version when it is "draft" (see models/workflow.py).
_DRAFT_WORKFLOW_VERSION = "draft"
logger = logging.getLogger(__name__)
class AgentComposerService:
@classmethod
@ -111,9 +108,7 @@ class AgentComposerService:
agent_id=agent.id if agent else None,
version_id=binding.current_snapshot_id,
)
state = cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
state["validation"] = cls.collect_validation_findings(tenant_id=tenant_id, payload=payload)
return state
return cls._serialize_workflow_state(binding=binding, agent=agent, version=version)
@classmethod
def load_agent_app_composer(cls, *, tenant_id: str, app_id: str) -> dict[str, Any]:
@ -210,241 +205,42 @@ class AgentComposerService:
agent.updated_by = account_id
db.session.commit()
state = cls.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id)
state["validation"] = cls.collect_validation_findings(tenant_id=tenant_id, payload=payload)
return state
return cls.load_agent_app_composer(tenant_id=tenant_id, app_id=app_id)
@classmethod
def collect_validation_findings(cls, *, tenant_id: str, payload: ComposerSavePayload) -> dict[str, Any]:
"""ENG-617 soft findings, with DB-backed dataset existence for placeholders."""
from services.agent.prompt_mentions import MentionKind, parse_prompt_mentions
mentioned_ids: set[str] = set()
if payload.agent_soul is not None:
mentioned_ids |= {
mention.ref_id
for mention in parse_prompt_mentions(payload.agent_soul.prompt.system_prompt)
if mention.kind == MentionKind.KNOWLEDGE
}
existing_dataset_ids: set[str] | None = None
if mentioned_ids:
existing_dataset_ids = set(cls._dataset_rows(tenant_id=tenant_id, dataset_ids=sorted(mentioned_ids)))
return ComposerConfigValidator.collect_soft_findings(payload, existing_dataset_ids=existing_dataset_ids)
@classmethod
def get_workflow_candidates(cls, *, tenant_id: str, app_id: str, node_id: str, user_id: str) -> dict[str, Any]:
"""Slash-menu data source for the workflow Agent node composer (ENG-615)."""
from services.agent.composer_candidates import previous_node_output_candidates, soul_candidates
try:
workflow = cls._get_draft_workflow(tenant_id=tenant_id, app_id=app_id)
except ValueError:
workflow = None
node_job: WorkflowNodeJobConfig | None = None
agent_soul: AgentSoulConfig | None = None
if workflow is not None:
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow.id, node_id=node_id)
if binding is not None:
node_job = cls._parse_node_job(binding)
agent_soul = cls._load_binding_soul(tenant_id=tenant_id, binding=binding)
truncated = False
previous_outputs: list[dict[str, Any]] = []
if workflow is not None:
draft_variable_session = cls._draft_variable_session()
try:
previous_outputs, outputs_truncated = previous_node_output_candidates(
graph=workflow.graph_dict,
node_id=node_id,
declared_outputs_loader=lambda nid: cls._binding_declared_outputs(
tenant_id=tenant_id, workflow_id=workflow.id, node_id=nid
),
draft_variables_loader=lambda nid: cls._draft_node_variables(
session=draft_variable_session, app_id=app_id, node_id=nid, user_id=user_id
),
system_variables_loader=lambda: cls._draft_system_variables(
session=draft_variable_session, app_id=app_id, user_id=user_id
),
)
finally:
draft_variable_session.close()
truncated = truncated or outputs_truncated
soul_lists, soul_truncated = soul_candidates(
agent_soul=agent_soul,
dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id),
)
truncated = truncated or soul_truncated
def get_workflow_candidates(cls, *, app_id: str) -> dict[str, Any]:
response = ComposerCandidatesResponse(
variant=ComposerVariant.WORKFLOW,
allowed_node_job_candidates={
"previous_node_outputs": previous_outputs,
"previous_node_outputs": [],
"declare_output_types": ["string", "number", "object", "array", "boolean", "file"],
"human_contacts": [
contact.model_dump(exclude_none=True) for contact in (node_job.human_contacts if node_job else [])
],
"human_contacts": [],
},
allowed_soul_candidates={
"skills_files": [],
"dify_tools": [],
"cli_tools": [],
"knowledge_datasets": [],
"human_contacts": [],
},
allowed_soul_candidates=soul_lists,
truncated=truncated,
)
return response.model_dump(mode="json")
@classmethod
def get_agent_app_candidates(cls, *, tenant_id: str, app_id: str, user_id: str) -> dict[str, Any]:
"""Slash-menu data source for the Agent App (Console) composer (ENG-615)."""
from services.agent.composer_candidates import soul_candidates
agent_soul = cls._load_agent_app_soul(tenant_id=tenant_id, app_id=app_id)
soul_lists, truncated = soul_candidates(
agent_soul=agent_soul,
dataset_lookup=lambda ids: cls._dataset_rows(tenant_id=tenant_id, dataset_ids=ids),
workspace_tools_loader=lambda: cls._workspace_dify_tools(tenant_id=tenant_id, user_id=user_id),
)
def get_agent_app_candidates(cls, *, app_id: str) -> dict[str, Any]:
response = ComposerCandidatesResponse(
variant=ComposerVariant.AGENT_APP,
allowed_node_job_candidates={},
allowed_soul_candidates=soul_lists,
truncated=truncated,
allowed_soul_candidates={
"skills_files": [],
"dify_tools": [],
"cli_tools": [],
"knowledge_datasets": [],
"human_contacts": [],
},
)
return response.model_dump(mode="json")
# ── candidates IO helpers (ENG-615) ──────────────────────────────────────
@staticmethod
def _parse_node_job(binding: WorkflowAgentNodeBinding) -> WorkflowNodeJobConfig | None:
try:
return WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict)
except Exception:
logger.warning("candidates: malformed node_job_config for binding %s", binding.id, exc_info=True)
return None
@classmethod
def _load_binding_soul(cls, *, tenant_id: str, binding: WorkflowAgentNodeBinding) -> AgentSoulConfig | None:
agent = cls._get_agent_if_present(tenant_id=tenant_id, agent_id=binding.agent_id)
version = cls._get_version_if_present(
tenant_id=tenant_id,
agent_id=agent.id if agent else None,
version_id=binding.current_snapshot_id,
)
return cls._parse_soul_snapshot(version)
@classmethod
def _load_agent_app_soul(cls, *, tenant_id: str, app_id: str) -> AgentSoulConfig | None:
agent = db.session.scalar(
select(Agent)
.where(
Agent.tenant_id == tenant_id,
Agent.app_id == app_id,
Agent.scope == AgentScope.ROSTER,
Agent.status == AgentStatus.ACTIVE,
)
.order_by(Agent.created_at.desc())
.limit(1)
)
if agent is None:
return None
version = cls._get_version_if_present(
tenant_id=tenant_id, agent_id=agent.id, version_id=agent.active_config_snapshot_id
)
return cls._parse_soul_snapshot(version)
@staticmethod
def _parse_soul_snapshot(version: AgentConfigSnapshot | None) -> AgentSoulConfig | None:
if version is None:
return None
try:
return AgentSoulConfig.model_validate(version.config_snapshot_dict)
except Exception:
logger.warning("candidates: malformed soul snapshot %s", version.id, exc_info=True)
return None
@classmethod
def _binding_declared_outputs(
cls, *, tenant_id: str, workflow_id: str, node_id: str
) -> list[DeclaredOutputConfig] | None:
binding = cls._get_workflow_binding(tenant_id=tenant_id, workflow_id=workflow_id, node_id=node_id)
if binding is None:
return None
node_job = cls._parse_node_job(binding)
if node_job is None:
return None
return list(_effective_declared_outputs(node_job.declared_outputs))
@staticmethod
def _draft_variable_session():
from sqlalchemy.orm import sessionmaker
return sessionmaker(bind=db.engine, expire_on_commit=False)()
@staticmethod
def _draft_node_variables(*, session: Any, app_id: str, node_id: str, user_id: str) -> list[tuple[str, str | None]]:
from services.workflow_draft_variable_service import WorkflowDraftVariableService
variables = WorkflowDraftVariableService(session=session).list_node_variables(app_id, node_id, user_id)
return [(variable.name, variable.value_type.value) for variable in variables.variables]
@staticmethod
def _draft_system_variables(*, session: Any, app_id: str, user_id: str) -> list[tuple[str, str | None]]:
from services.workflow_draft_variable_service import WorkflowDraftVariableService
variables = WorkflowDraftVariableService(session=session).list_system_variables(app_id, user_id)
return [(variable.name, variable.value_type.value) for variable in variables.variables]
@staticmethod
def _dataset_rows(*, tenant_id: str, dataset_ids: list[str]) -> dict[str, Any]:
"""Tenant-scoped dataset lookup tolerating malformed ids.
Mention ids come from user-editable prompt text; a non-UUID id can never
match a dataset row, so it is simply absent from the result (-> missing/
placeholder semantics) instead of breaking the UUID-typed query.
"""
from uuid import UUID
from services.dataset_service import DatasetService
valid_ids: list[str] = []
for dataset_id in dataset_ids:
try:
UUID(dataset_id)
except (ValueError, TypeError):
continue
valid_ids.append(dataset_id)
if not valid_ids:
return {}
rows, _ = DatasetService.get_datasets_by_ids(valid_ids, tenant_id)
return {str(row.id): row for row in rows}
@staticmethod
def _workspace_dify_tools(*, tenant_id: str, user_id: str) -> list[dict[str, Any]]:
"""Workspace Dify Plugin tools, same source as the tool selector.
A plugin-daemon outage must degrade the slash menu to an empty tools
tab, not break the whole candidates endpoint.
"""
from services.tools.builtin_tools_manage_service import BuiltinToolManageService
try:
providers = BuiltinToolManageService.list_builtin_tools(user_id, tenant_id)
except Exception:
logger.warning("candidates: failed to list workspace tools for tenant %s", tenant_id, exc_info=True)
return []
tools: list[dict[str, Any]] = []
for provider in providers:
for tool in provider.tools or []:
tools.append(
{
"id": f"{provider.name}/{tool.name}",
"name": tool.name,
"description": tool.label.en_US if tool.label else tool.name,
"provider": provider.name,
"plugin_id": provider.plugin_id or None,
}
)
return tools
@classmethod
def calculate_impact(cls, *, tenant_id: str, current_snapshot_id: str) -> dict[str, Any]:
bindings = list(

View File

@ -4,17 +4,6 @@ from typing import Any
from pydantic import ValidationError
from services.agent.errors import AgentSoulLockedError, InvalidComposerConfigError, PlaintextSecretNotAllowedError
from services.agent.prompt_mentions import (
MAX_MENTIONS_PER_PROMPT,
NODE_JOB_PROMPT_ALLOWED_KINDS,
SOUL_PROMPT_ALLOWED_KINDS,
MentionKind,
MentionResolver,
build_node_job_mention_resolver,
build_soul_mention_resolver,
find_malformed_mention_markers,
parse_prompt_mentions,
)
from services.entities.agent_entities import (
AgentSoulConfig,
ComposerSavePayload,
@ -57,158 +46,6 @@ class ComposerConfigValidator:
cls.validate_agent_soul(payload.agent_soul)
if payload.node_job is not None:
cls.validate_node_job(payload.node_job)
cls._validate_prompt_mentions(payload)
@classmethod
def _validate_prompt_mentions(cls, payload: ComposerSavePayload) -> None:
"""ENG-616 §2.4 allowlists + ENG-617 §5.2 human-must-be-referenced.
Error messages start with a stable code token (``mention_kind_not_allowed``
/ ``mention_limit_exceeded`` / ``human_involvement_not_referenced``) so
the frontend can switch on it.
"""
if payload.agent_soul is not None:
cls._validate_surface_mentions(
prompt=payload.agent_soul.prompt.system_prompt,
allowed=SOUL_PROMPT_ALLOWED_KINDS,
surface="agent soul prompt",
)
cls._require_human_mentions(
prompt=payload.agent_soul.prompt.system_prompt,
contacts=payload.agent_soul.human.contacts,
surface="agent soul prompt",
)
if payload.node_job is not None:
cls._validate_surface_mentions(
prompt=payload.node_job.workflow_prompt,
allowed=NODE_JOB_PROMPT_ALLOWED_KINDS,
surface="workflow job prompt",
)
cls._require_human_mentions(
prompt=payload.node_job.workflow_prompt,
contacts=payload.node_job.human_contacts,
surface="workflow job prompt",
)
@classmethod
def _validate_surface_mentions(cls, *, prompt: str, allowed: frozenset[MentionKind], surface: str) -> None:
mentions = parse_prompt_mentions(prompt)
if len(mentions) > MAX_MENTIONS_PER_PROMPT:
raise InvalidComposerConfigError(
f"mention_limit_exceeded: {surface} has {len(mentions)} mentions, "
f"exceeding the limit of {MAX_MENTIONS_PER_PROMPT}."
)
for mention in mentions:
if mention.kind not in allowed:
raise InvalidComposerConfigError(
f"mention_kind_not_allowed: {surface} cannot reference {mention.kind.value} (id={mention.ref_id})."
)
@classmethod
def _require_human_mentions(cls, *, prompt: str, contacts: list[Any], surface: str) -> None:
"""ENG-617 §5.2 (PRD: human involvement must be slash-referenced or save errors).
Every configured human contact must appear as ``{{#human:<id>#}}`` in the
corresponding prompt. A contact matches via any identity alias; contacts
carrying no identity at all cannot be referenced and are skipped.
"""
if not contacts:
return
mentioned = {mention.ref_id for mention in parse_prompt_mentions(prompt) if mention.kind == MentionKind.HUMAN}
for contact in contacts:
aliases = {
alias
for alias in (contact.id, contact.contact_id, contact.human_id, contact.email, contact.name)
if alias
}
if not aliases:
continue
if aliases.isdisjoint(mentioned):
display = contact.name or contact.email or contact.id or "human involvement"
raise InvalidComposerConfigError(
f"human_involvement_not_referenced: configured human involvement '{display}' "
f"must be referenced in the {surface} via the slash menu."
)
@classmethod
def collect_soft_findings(
cls,
payload: ComposerSavePayload,
*,
existing_dataset_ids: set[str] | None = None,
) -> dict[str, Any]:
"""ENG-617 §5.3/§5.4 soft findings — never block save.
``warnings`` carries ``mention_target_missing`` / ``mention_malformed``
entries; ``knowledge_retrieval_placeholder`` keeps dangling knowledge
mentions with a placeholder name (0522 consensus) instead of dropping or
rejecting them. With ``existing_dataset_ids`` provided, configured-but-
deleted datasets surface as placeholders too.
"""
warnings: list[dict[str, Any]] = []
placeholders: list[dict[str, str]] = []
surfaces: list[tuple[str, str, MentionResolver, frozenset[MentionKind]]] = []
if payload.agent_soul is not None:
surfaces.append(
(
"agent_soul",
payload.agent_soul.prompt.system_prompt,
build_soul_mention_resolver(payload.agent_soul),
SOUL_PROMPT_ALLOWED_KINDS,
)
)
if payload.node_job is not None:
surfaces.append(
(
"node_job",
payload.node_job.workflow_prompt,
build_node_job_mention_resolver(payload.node_job),
NODE_JOB_PROMPT_ALLOWED_KINDS,
)
)
for surface, prompt, resolver, allowed in surfaces:
for mention in parse_prompt_mentions(prompt):
if mention.kind not in allowed:
continue # hard-rejected by validate_save_payload
resolved = resolver(mention)
if mention.kind == MentionKind.KNOWLEDGE:
dangling = resolved is None or (
existing_dataset_ids is not None and mention.ref_id not in existing_dataset_ids
)
if dangling:
placeholders.append(
{
"id": mention.ref_id,
"placeholder_name": mention.label or f"Knowledge {mention.ref_id[:8]}",
}
)
continue
if resolved is None:
warnings.append(
{
"code": "mention_target_missing",
"surface": surface,
"kind": mention.kind.value,
"id": mention.ref_id,
"message": f"{mention.kind.value} mention (id={mention.ref_id}) does not match "
"any configured item.",
}
)
for marker in find_malformed_mention_markers(prompt):
warnings.append(
{
"code": "mention_malformed",
"surface": surface,
"kind": None,
"id": None,
"message": f"mention-shaped marker {marker!r} is malformed and will be "
"degraded to plain text at runtime.",
}
)
return {"warnings": warnings, "knowledge_retrieval_placeholder": placeholders}
@classmethod
def validate_agent_soul(cls, agent_soul: AgentSoulConfig) -> None:

View File

@ -1,264 +0,0 @@
"""Prompt mention (slash-reference) serialization contract — ENG-616.
Slash-menu insertions are stored inline in the plain-string prompt as tokens:
[§<kind>:<id>[:<label>]§]
``kind`` is a fixed lowercase word; ``id`` points at an item in the Agent config
lists (mentions are pointers — the entity itself lives in ``skills_files`` /
``tools`` / ``knowledge.datasets`` / ``human.contacts`` /
``previous_node_output_refs`` / ``declared_outputs``); ``label`` is an optional
plain-text fallback only (the backend always re-resolves by id, so renames never
break references). A single ``:`` separates all three fields; ``label`` is the
trailing remainder and may itself contain ``:``.
The ``[§…§]`` wrapper uses the section sign ``§`` (U+00A7), which never appears
in Dify template syntax (``{{var}}`` / ``{{#a.b#}}``) nor in normal prompt text,
so these tokens can never collide with the existing template parsers. Runtime
expansion (and the final scrub that guarantees no internal marker ever reaches
the model) is owned by the run-request builders.
"""
from __future__ import annotations
import re
from collections.abc import Callable
from dataclasses import dataclass
from enum import StrEnum
from models.agent_config_entities import (
AgentHumanContactConfig,
AgentSoulConfig,
WorkflowNodeJobConfig,
WorkflowPreviousNodeOutputRef,
)
class MentionKind(StrEnum):
SKILL = "skill"
FILE = "file"
TOOL = "tool"
CLI_TOOL = "cli_tool"
KNOWLEDGE = "knowledge"
HUMAN = "human"
NODE_OUTPUT = "node_output"
OUTPUT = "output"
MENTION_PATTERN = re.compile(
r"\[§(skill|file|tool|cli_tool|knowledge|human|node_output|output):([^:§]+?)(?::([^§]*?))?§\]"
)
# Anything mention-shaped (``[§word:…§]``) that the strict pattern did not consume
# — unknown kinds, malformed bodies. The ``§`` wrapper + a kind-word + ``:``
# requirement keeps legacy ``{{#histories#}}`` / ``{{var}}`` template forms and
# ordinary bracketed text out of scope.
_RESIDUAL_MENTION_PATTERN = re.compile(r"\[§([A-Za-z_][A-Za-z0-9_]*:[^§]*?)§\]")
MAX_MENTIONS_PER_PROMPT = 200
MAX_MENTION_FIELD_LENGTH = 255
# Per-surface allowlists (design §2.4): the soul prompt may only reference
# soul-owned entities; the workflow job prompt may only reference run-scoped ones.
SOUL_PROMPT_ALLOWED_KINDS = frozenset(
{
MentionKind.SKILL,
MentionKind.FILE,
MentionKind.TOOL,
MentionKind.CLI_TOOL,
MentionKind.KNOWLEDGE,
MentionKind.HUMAN,
}
)
NODE_JOB_PROMPT_ALLOWED_KINDS = frozenset({MentionKind.NODE_OUTPUT, MentionKind.OUTPUT, MentionKind.HUMAN})
@dataclass(frozen=True, slots=True)
class PromptMention:
kind: MentionKind
ref_id: str
label: str | None
start: int
end: int
raw: str
# Returns the model-readable replacement for a mention, or None when the id does
# not resolve (the expander then degrades to label/id).
MentionResolver = Callable[[PromptMention], str | None]
def parse_prompt_mentions(prompt: str) -> list[PromptMention]:
"""Extract well-formed mentions. Oversized id/label tokens are skipped here
(treated as malformed) — the runtime scrub still degrades them safely."""
mentions: list[PromptMention] = []
for match in MENTION_PATTERN.finditer(prompt or ""):
ref_id = match.group(2)
label = match.group(3)
if len(ref_id) > MAX_MENTION_FIELD_LENGTH or (label is not None and len(label) > MAX_MENTION_FIELD_LENGTH):
continue
mentions.append(
PromptMention(
kind=MentionKind(match.group(1)),
ref_id=ref_id,
label=label or None,
start=match.start(),
end=match.end(),
raw=match.group(0),
)
)
return mentions
def expand_prompt_mentions(prompt: str, resolver: MentionResolver) -> str:
"""Replace every mention with resolver output, degrading unresolved ones to
their label (then id), and scrub any residual mention-shaped marker so no
frontend-internal token ever reaches the model."""
if not prompt:
return prompt
def _replace(match: re.Match[str]) -> str:
ref_id = match.group(2)
label = match.group(3) or None
fallback = (label or ref_id)[:MAX_MENTION_FIELD_LENGTH]
if len(ref_id) > MAX_MENTION_FIELD_LENGTH or (label is not None and len(label) > MAX_MENTION_FIELD_LENGTH):
return fallback
mention = PromptMention(
kind=MentionKind(match.group(1)),
ref_id=ref_id,
label=label,
start=match.start(),
end=match.end(),
raw=match.group(0),
)
resolved = resolver(mention)
if resolved is None or not resolved.strip():
return fallback
return resolved[:MAX_MENTION_FIELD_LENGTH]
return scrub_mention_markers(MENTION_PATTERN.sub(_replace, prompt))
def find_malformed_mention_markers(prompt: str) -> list[str]:
"""Mention-shaped markers the strict grammar does not accept (unknown kind,
oversized id/label, broken body). Soft-flagged at validate; the runtime
scrub still degrades them safely."""
if not prompt:
return []
parsed_spans = {(mention.start, mention.end) for mention in parse_prompt_mentions(prompt)}
return [match.group(0) for match in _RESIDUAL_MENTION_PATTERN.finditer(prompt) if match.span() not in parsed_spans]
def scrub_mention_markers(text: str) -> str:
"""Degrade any residual mention-shaped ``[§kind:…§]`` marker to readable text."""
def _degrade(match: re.Match[str]) -> str:
# inner is ``kind:id[:label]``; prefer the label, else the id.
parts = match.group(1).split(":", 2)
if len(parts) >= 3 and parts[2].strip():
return parts[2].strip()[:MAX_MENTION_FIELD_LENGTH]
if len(parts) >= 2 and parts[1].strip():
return parts[1].strip()[:MAX_MENTION_FIELD_LENGTH]
return match.group(1)[:MAX_MENTION_FIELD_LENGTH]
return _RESIDUAL_MENTION_PATTERN.sub(_degrade, text)
def build_soul_mention_resolver(agent_soul: AgentSoulConfig) -> MentionResolver:
"""Resolve soul-surface mentions to canonical display names from the soul config."""
def _resolve(mention: PromptMention) -> str | None:
match mention.kind:
case MentionKind.SKILL:
for skill in agent_soul.skills_files.skills:
if mention.ref_id in (skill.id, skill.name):
return skill.name or skill.id
case MentionKind.FILE:
for file in agent_soul.skills_files.files:
if mention.ref_id in (file.id, file.name):
return file.name or file.id
case MentionKind.TOOL:
for tool in agent_soul.tools.dify_tools:
aliases = {tool.tool_name} | {
f"{prefix}/{tool.tool_name}"
for prefix in (tool.provider, tool.provider_id, tool.plugin_id)
if prefix
}
if mention.ref_id in aliases:
return tool.name or tool.tool_name
case MentionKind.CLI_TOOL:
for cli_tool in agent_soul.tools.cli_tools:
if cli_tool.name and mention.ref_id == cli_tool.name:
return cli_tool.name
case MentionKind.KNOWLEDGE:
for dataset in agent_soul.knowledge.datasets:
if mention.ref_id == dataset.id:
return dataset.name or dataset.id
case MentionKind.HUMAN:
return _resolve_human_contact(agent_soul.human.contacts, mention.ref_id)
case _:
return None
return None
return _resolve
def build_node_job_mention_resolver(node_job: WorkflowNodeJobConfig) -> MentionResolver:
"""Resolve job-surface mentions. ``node_output`` expands to the stored
reference name only — values stay in the Workflow context block (design §4.2)."""
def _resolve(mention: PromptMention) -> str | None:
match mention.kind:
case MentionKind.NODE_OUTPUT:
for ref in node_job.previous_node_output_refs:
selector = _selector_from_ref(ref)
if selector and f"{selector[0]}.{selector[1]}" == mention.ref_id:
return ref.name or mention.label or mention.ref_id
case MentionKind.OUTPUT:
for output in node_job.declared_outputs:
if output.name == mention.ref_id:
return f"{output.name} ({output.type.value})"
case MentionKind.HUMAN:
return _resolve_human_contact(node_job.human_contacts, mention.ref_id)
case _:
return None
return None
return _resolve
def _resolve_human_contact(contacts: list[AgentHumanContactConfig], ref_id: str) -> str | None:
for contact in contacts:
if ref_id in (contact.id, contact.contact_id, contact.human_id):
channel = contact.channel or contact.method or contact.contact_method
who = contact.name or contact.email or ref_id
return f"{channel.upper()} · {who}" if channel else who
return None
def _selector_from_ref(ref: WorkflowPreviousNodeOutputRef) -> tuple[str, str] | None:
for candidate in (ref.selector, ref.variable_selector, ref.value_selector):
if isinstance(candidate, list) and len(candidate) >= 2:
return str(candidate[0]), str(candidate[1])
if ref.node_id:
output = ref.output or ref.variable or ref.key
if output:
return ref.node_id, output
return None
__all__ = [
"MAX_MENTIONS_PER_PROMPT",
"MAX_MENTION_FIELD_LENGTH",
"MENTION_PATTERN",
"NODE_JOB_PROMPT_ALLOWED_KINDS",
"SOUL_PROMPT_ALLOWED_KINDS",
"MentionKind",
"MentionResolver",
"PromptMention",
"build_node_job_mention_resolver",
"build_soul_mention_resolver",
"expand_prompt_mentions",
"find_malformed_mention_markers",
"parse_prompt_mentions",
"scrub_mention_markers",
]

View File

@ -91,5 +91,3 @@ class ComposerCandidatesResponse(BaseModel):
allowed_node_job_candidates: dict[str, Any] = Field(default_factory=dict)
allowed_soul_candidates: dict[str, Any] = Field(default_factory=dict)
capabilities: ComposerCandidateCapabilities = Field(default_factory=ComposerCandidateCapabilities)
# True when any candidate list was clipped to the per-list cap (ENG-615 §3.3).
truncated: bool = False

View File

@ -24,7 +24,6 @@ from controllers.console.agent.roster import (
AgentRosterVersionDetailApi,
AgentRosterVersionsApi,
)
from models.model import AppMode
from services.entities.agent_entities import ComposerSaveStrategy, ComposerVariant
@ -112,22 +111,6 @@ def _candidates_response(variant: str) -> dict:
}
def _get_app_model_modes(view) -> list[AppMode]:
current = view
while current is not None:
closure = getattr(current, "__closure__", None)
if closure is not None:
for cell in closure:
try:
value = cell.cell_contents
except ValueError:
continue
if isinstance(value, list) and all(isinstance(item, AppMode) for item in value):
return value
current = getattr(current, "__wrapped__", None)
return []
class _PayloadWithDescription(Protocol):
description: object
@ -306,12 +289,12 @@ def test_workflow_composer_get_put_validate_candidates_impact_and_save(
)
assert saved_state["save_options"] == ["node_job_only"]
assert unwrap(WorkflowAgentComposerValidateApi.post)(
WorkflowAgentComposerValidateApi(), "tenant-1", app_model, "node-1"
) == {"result": "success", "errors": [], "warnings": [], "knowledge_retrieval_placeholder": []}
WorkflowAgentComposerValidateApi(), app_model, "node-1"
) == {"result": "success", "errors": []}
assert (
unwrap(WorkflowAgentComposerCandidatesApi.get)(
WorkflowAgentComposerCandidatesApi(), "tenant-1", account_id, app_model, "node-1"
)["variant"]
unwrap(WorkflowAgentComposerCandidatesApi.get)(WorkflowAgentComposerCandidatesApi(), app_model, "node-1")[
"variant"
]
== "workflow"
)
with app.test_request_context(json=payload):
@ -366,20 +349,9 @@ def test_agent_app_composer_get_put_validate_and_candidates(
unwrap(AgentAppComposerApi.put)(AgentAppComposerApi(), "tenant-1", account_id, app_model)["variant"]
== "agent_app"
)
assert unwrap(AgentAppComposerValidateApi.post)(AgentAppComposerValidateApi(), "tenant-1", app_model) == {
assert unwrap(AgentAppComposerValidateApi.post)(AgentAppComposerValidateApi(), app_model) == {
"result": "success",
"errors": [],
"warnings": [],
"knowledge_retrieval_placeholder": [],
}
agent_app_candidates = unwrap(AgentAppComposerCandidatesApi.get)(
AgentAppComposerCandidatesApi(), "tenant-1", account_id, app_model
)
agent_app_candidates = unwrap(AgentAppComposerCandidatesApi.get)(AgentAppComposerCandidatesApi(), app_model)
assert agent_app_candidates["variant"] == "agent_app"
def test_agent_app_composer_routes_are_agent_mode_only() -> None:
assert _get_app_model_modes(AgentAppComposerApi.get) == [AppMode.AGENT]
assert _get_app_model_modes(AgentAppComposerApi.put) == [AppMode.AGENT]
assert _get_app_model_modes(AgentAppComposerValidateApi.post) == [AppMode.AGENT]
assert _get_app_model_modes(AgentAppComposerCandidatesApi.get) == [AppMode.AGENT]

View File

@ -14,7 +14,7 @@ import pytest
from flask import Flask
from controllers.console.auth.activate import ActivateApi, ActivateCheckApi
from controllers.console.error import AccountInFreezeError, AlreadyActivateError
from controllers.console.error import AlreadyActivateError
from models.account import AccountStatus
@ -255,47 +255,6 @@ class TestActivateApi:
with pytest.raises(AlreadyActivateError):
api.post()
@patch("controllers.console.auth.activate.dify_config.BILLING_ENABLED", True)
@patch("controllers.console.auth.activate.BillingService.is_email_in_freeze")
@patch("controllers.console.auth.activate.RegisterService.get_invitation_if_token_valid")
@patch("controllers.console.auth.activate.RegisterService.revoke_token")
@patch("controllers.console.auth.activate.db")
def test_activation_rejects_account_in_billing_freeze(
self,
mock_db,
mock_revoke_token,
mock_get_invitation,
mock_is_email_in_freeze,
app: Flask,
mock_invitation,
mock_account,
):
"""Frozen deleted-account emails cannot be reactivated through invitation links."""
mock_account.email = "Invitee@Example.com"
mock_get_invitation.return_value = mock_invitation
mock_is_email_in_freeze.return_value = True
with app.test_request_context(
"/activate",
method="POST",
json={
"workspace_id": "workspace-123",
"email": "invitee@example.com",
"token": "valid_token",
"name": "John Doe",
"interface_language": "en-US",
"timezone": "UTC",
},
):
api = ActivateApi()
with pytest.raises(AccountInFreezeError):
api.post()
mock_is_email_in_freeze.assert_called_once_with("Invitee@Example.com")
mock_revoke_token.assert_not_called()
mock_db.session.commit.assert_not_called()
assert mock_account.status == AccountStatus.PENDING
@patch("controllers.console.auth.activate.RegisterService.get_invitation_with_case_fallback")
@patch("controllers.console.auth.activate.RegisterService.revoke_token")
@patch("controllers.console.auth.activate.db")

View File

@ -208,41 +208,3 @@ def test_accepts_body_emits_expect_through_guard_stack():
apidoc = getattr(view, "__apidoc__", {})
assert apidoc.get("expect") # body schema advertised via @openapi_ns.expect
def _response_model_name(entry) -> str:
"""Extract the model name from a flask-restx __apidoc__ response entry.
flask-restx stores responses as ``(description, model, kwargs)`` tuples
where ``model.name`` is the registered schema name.
"""
if isinstance(entry, tuple) and len(entry) >= 2:
model = entry[1]
return getattr(model, "name", "") or ""
return ""
def test_accepts_documents_422_error_response(app):
from controllers.openapi._errors import ErrorBody
@accepts(query=ContractQuery)
def view(*, query):
return query
doc = getattr(view, "__apidoc__", {})
responses = doc.get("responses", {})
assert "422" in responses
assert _response_model_name(responses["422"]) == ErrorBody.__name__
def test_returns_documents_default_error_response(app):
from controllers.openapi._errors import ErrorBody
@returns(200, ContractResp)
def view():
return ContractResp(value=1)
doc = getattr(view, "__apidoc__", {})
responses = doc.get("responses", {})
assert "default" in responses
assert _response_model_name(responses["default"]) == ErrorBody.__name__

View File

@ -1,337 +0,0 @@
"""Wire-contract tests for the canonical /openapi/v1 error body."""
from unittest.mock import MagicMock, patch
import pytest
from werkzeug.exceptions import (
BadGateway,
BadRequest,
Conflict,
Forbidden,
InternalServerError,
NotFound,
Unauthorized,
UnprocessableEntity,
)
from controllers.common.errors import (
BlockedFileExtensionError,
FileTooLargeError,
NoFileUploadedError,
TooManyFilesError,
UnsupportedFileTypeError,
)
from controllers.openapi._errors import (
ErrorBody,
ErrorDetail,
FilenameNotExists,
MemberLicenseExceeded,
MemberLimitExceeded,
OpenApiError,
OpenApiErrorCode,
OpenApiErrorFormatter,
)
from controllers.service_api.app.error import (
AppUnavailableError,
CompletionRequestError,
ConversationCompletedError,
ProviderModelCurrentlyNotSupportError,
ProviderNotInitializeError,
ProviderQuotaExceededError,
)
from controllers.web.error import InvokeRateLimitError as InvokeRateLimitHttpError
@pytest.fixture
def fmt() -> OpenApiErrorFormatter:
return OpenApiErrorFormatter()
class TestErrorBodyModel:
def test_minimal_body_serializes_without_optional_fields(self):
body = ErrorBody(code=OpenApiErrorCode.NOT_FOUND, message="app not found", status=404)
wire = body.model_dump(mode="json", exclude_none=True)
assert wire == {"code": "not_found", "message": "app not found", "status": 404}
def test_full_body_round_trips(self):
body = ErrorBody(
code=OpenApiErrorCode.INVALID_PARAM,
message="Request validation failed",
status=422,
hint="check the request payload",
details=[ErrorDetail(type="int_parsing", loc=["page"], msg="must be >= 1")],
)
wire = body.model_dump(mode="json", exclude_none=True)
assert wire["details"] == [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1"}]
assert ErrorBody.model_validate(wire) == body
def test_code_field_is_open_string_for_forward_compat(self):
# Old CLIs must not hard-fail when a future server adds a code, so the
# schema type is str; enum membership is enforced by the formatter tests.
body = ErrorBody.model_validate({"code": "some_future_code", "message": "x", "status": 400})
assert body.code == "some_future_code"
class TestOpenApiErrorFormatter:
def test_plain_werkzeug_exception_maps_code_from_status(self, fmt):
e = NotFound("app not found")
data = {"code": "not_found", "message": "app not found", "status": 404}
wire = fmt.finalize(e, data, 404)
assert wire == {"code": "not_found", "message": "app not found", "status": 404}
def test_422_maps_to_invalid_param(self, fmt):
e = UnprocessableEntity("workspace_id is required for name-based lookup")
data = {"code": "unprocessable_entity", "message": e.description, "status": 422}
wire = fmt.finalize(e, data, 422)
assert wire["code"] == "invalid_param"
def test_flask_restx_abort_data_path_yields_canonical_body(self, fmt):
# Simulates _contract.py's abort(422, message=..., errors=...): flask_restx
# attaches kwargs to e.data, which handle_error would otherwise put on the
# wire verbatim (no code/status).
e = UnprocessableEntity()
e.data = {
"message": "Request validation failed",
"errors": [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1", "extra": "drop me"}],
}
data = {"code": "unprocessable_entity", "message": e.description, "status": 422}
wire = fmt.finalize(e, data, 422)
assert wire["code"] == "invalid_param"
assert wire["message"] == "Request validation failed"
assert wire["status"] == 422
assert wire["details"] == [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1"}]
# the override channel now carries the canonical body
assert e.data == wire
def test_finalize_is_idempotent(self, fmt):
e = UnprocessableEntity()
e.data = {
"message": "Request validation failed",
"errors": [{"type": "int_parsing", "loc": ["page"], "msg": "must be >= 1"}],
}
data = {"code": "unprocessable_entity", "message": e.description, "status": 422}
first = fmt.finalize(e, data, 422)
second = fmt.finalize(e, data, 422)
assert second == first
def test_malformed_canonical_details_falls_back_instead_of_raising(self, fmt):
# finalize runs inside the framework error handler; a ValidationError
# escaping it would replace the response with an unformatted 500
e = UnprocessableEntity()
e.data = {"message": "broken", "details": [{"bad": "shape"}]}
data = {"code": "unprocessable_entity", "message": "broken", "status": 422}
wire = fmt.finalize(e, data, 422)
assert wire == {"code": "invalid_param", "message": "Unprocessable Entity", "status": 422}
def test_base_http_exception_error_code_wins_over_status_map(self, fmt):
e = ProviderQuotaExceededError()
data = dict(e.data)
wire = fmt.finalize(e, data, 400)
assert wire["code"] == "provider_quota_exceeded"
assert wire["status"] == 400
def test_hint_attribute_is_emitted(self, fmt):
e = Conflict("seat limit")
e.hint = "remove a member first"
data = {"code": "conflict", "message": "seat limit", "status": 409}
wire = fmt.finalize(e, data, 409)
assert wire["hint"] == "remove a member first"
def test_params_shape_becomes_details(self, fmt):
e = ValueError("is required")
data = {"code": "invalid_param", "message": "is required", "params": "email", "status": 400}
wire = fmt.finalize(e, data, 400)
assert "params" not in wire
assert wire["details"] == [{"type": "invalid", "loc": ["email"], "msg": "is required"}]
def test_catch_all_exception_never_leaks_str_e(self, fmt):
e = RuntimeError("postgres password=hunter2 connection refused")
data = {"message": str(e), "code": "unknown", "status": 500}
wire = fmt.finalize(e, data, 500)
assert wire["code"] == "internal_server_error"
assert "hunter2" not in wire["message"]
def test_unmapped_status_falls_back_to_unknown(self, fmt):
from werkzeug.exceptions import Gone
e = Gone()
data = {"code": "gone", "message": e.description, "status": 410}
wire = fmt.finalize(e, data, 410)
assert wire["code"] == "unknown"
def test_openapi_error_subclass_is_throw_and_done(self, fmt):
# The dedicated throwable: subclass declares status + code + message once,
# call sites just `raise`; the formatter emits everything verbatim.
class TeapotError(OpenApiError):
code = 418
error_code = OpenApiErrorCode.INVALID_PARAM
description = "kettle says no"
e = TeapotError(details=[ErrorDetail(type="invalid", loc=["kettle"], msg="too hot")])
data = {"code": "im_a_teapot", "message": e.description, "status": 418}
wire = fmt.finalize(e, data, 418)
assert wire["code"] == OpenApiErrorCode.INVALID_PARAM
assert wire["message"] == TeapotError.description
assert wire["details"] == [{"type": "invalid", "loc": ["kettle"], "msg": "too hot"}]
def test_openapi_error_message_override(self, fmt):
e = OpenApiError("custom reason")
data = {"code": "bad_request", "message": e.description, "status": 400}
wire = fmt.finalize(e, data, 400)
assert wire["message"] == "custom reason"
assert wire["code"] == "bad_request"
def test_every_emitted_code_is_an_enum_member(self, fmt):
# Guard against the formatter inventing codes outside the contract.
cases = [
(NotFound("x"), {"code": "not_found", "message": "x", "status": 404}, 404),
(ProviderQuotaExceededError(), dict(ProviderQuotaExceededError().data), 400),
(ValueError("x"), {"code": "invalid_param", "message": "x", "status": 400}, 400),
]
for e, data, status in cases:
wire = fmt.finalize(e, data, status)
assert wire["code"] in {c.value for c in OpenApiErrorCode}
class TestQuotaExceptions:
@pytest.mark.parametrize("exc_class", [MemberLimitExceeded, MemberLicenseExceeded])
def test_quota_exception_carries_declared_code_and_message(self, fmt, exc_class):
# Single source: assertions read the class attributes, no re-typed strings.
e = exc_class()
data = {"code": "forbidden", "message": e.description, "status": 403}
wire = fmt.finalize(e, data, 403)
assert wire["code"] == exc_class.error_code
assert wire["message"] == exc_class.description
assert wire["hint"] == exc_class.hint
assert wire["status"] == 403
class TestWireContract:
"""End-to-end: request in, canonical JSON out, through the real openapi blueprint."""
def test_accepts_422_carries_code_status_details(self, openapi_app, bypass_pipeline):
client = openapi_app.test_client()
resp = client.get("/openapi/v1/apps?page=0")
assert resp.status_code == 422
wire = resp.get_json()
ErrorBody.model_validate(wire)
assert wire["code"] == "invalid_param"
assert wire["status"] == 422
assert wire["details"]
def test_unknown_route_404_is_canonical_without_route_suggestions(self, openapi_app):
client = openapi_app.test_client()
resp = client.get("/openapi/v1/definitely-not-a-route")
assert resp.status_code == 404
wire = resp.get_json()
ErrorBody.model_validate(wire)
assert wire["code"] == "not_found"
assert "did you mean" not in wire["message"].lower()
def test_404_outside_blueprint_prefix_is_not_claimed(self, openapi_app):
# catch_all_404s wraps the app-level exception handler; the prefix
# guard must keep non-/openapi/v1 paths on the app's own 404 handling
client = openapi_app.test_client()
resp = client.get("/console/definitely-not-a-route")
assert resp.status_code == 404
# not intercepted → Flask's default HTML 404, not the canonical JSON body
assert "application/json" not in (resp.content_type or "")
@patch("controllers.openapi.oauth_device.DeviceFlowRedis")
def test_oauth_device_token_keeps_rfc8628_shape(self, mock_redis_cls, openapi_app):
store = MagicMock()
mock_redis_cls.return_value = store
store.record_poll.return_value = None # not SlowDownDecision.SLOW_DOWN
store.load_by_device_code.return_value = None # unknown code → expired_token
client = openapi_app.test_client()
resp = client.post(
"/openapi/v1/oauth/device/token",
json={"client_id": "difyctl", "device_code": "nope"},
)
assert resp.status_code == 400
wire = resp.get_json()
assert wire == {"error": "expired_token"}
ERROR_MATRIX = [
(BadRequest("x"), 400, "bad_request"),
(Unauthorized("x"), 401, "unauthorized"),
(Forbidden("x"), 403, "forbidden"),
(NotFound("x"), 404, "not_found"),
(Conflict("x"), 409, "conflict"),
(UnprocessableEntity("x"), 422, "invalid_param"),
(InternalServerError(), 500, "internal_server_error"),
(BadGateway("x"), 502, "bad_gateway"),
(AppUnavailableError(), 400, "app_unavailable"),
(ConversationCompletedError(), 400, "conversation_completed"),
(ProviderNotInitializeError(), 400, "provider_not_initialize"),
(ProviderQuotaExceededError(), 400, "provider_quota_exceeded"),
(ProviderModelCurrentlyNotSupportError(), 400, "model_currently_not_support"),
(CompletionRequestError(), 400, "completion_request_error"),
(InvokeRateLimitHttpError(), 429, "rate_limit_error"),
(FileTooLargeError(), 413, "file_too_large"),
(UnsupportedFileTypeError(), 415, "unsupported_file_type"),
(NoFileUploadedError(), 400, "no_file_uploaded"),
(TooManyFilesError(), 400, "too_many_files"),
(FilenameNotExists(), 400, "filename_not_exists"),
(BlockedFileExtensionError(), 400, "file_extension_blocked"),
(MemberLimitExceeded(), 403, "member_limit_exceeded"),
(MemberLicenseExceeded(), 403, "member_license_exceeded"),
]
class TestErrorMatrix:
@pytest.mark.parametrize(
("exc", "status", "expected_code"),
ERROR_MATRIX,
ids=lambda v: type(v).__name__ if isinstance(v, Exception) else str(v),
)
def test_every_known_error_path_yields_canonical_code(self, fmt, exc, status, expected_code):
data = dict(getattr(exc, "data", None) or {"message": str(exc), "status": status})
wire = fmt.finalize(exc, data, status)
assert wire["code"] == expected_code
assert wire["status"] == status
assert wire["code"] in {c.value for c in OpenApiErrorCode}
ErrorBody.model_validate(wire)

View File

@ -29,10 +29,9 @@ import pytest
from flask import Flask
from flask.views import MethodView
from pydantic import ValidationError
from werkzeug.exceptions import BadRequest, NotFound, UnprocessableEntity
from werkzeug.exceptions import BadRequest, Forbidden, NotFound, UnprocessableEntity
from controllers.openapi import bp as openapi_bp
from controllers.openapi._errors import MemberLicenseExceeded, MemberLimitExceeded
from controllers.openapi._models import MemberInvitePayload, MemberRoleUpdatePayload
from controllers.openapi.workspaces import (
WorkspaceMemberApi,
@ -508,7 +507,11 @@ def _invite_request(app, ws_id: str, acct_id: uuid.UUID):
def test_invite_blocked_by_saas_members_cap(app, bypass_pipeline, monkeypatch):
"""SaaS billing plan member cap → MemberLimitExceeded (403)."""
"""SaaS billing plan member cap → 403 with `members.limit_exceeded`.
Verifies the envelope shape the CLI error-mapper relies on (code +
message + hint on the wire body).
"""
ws_id = str(uuid.uuid4())
acct_id = uuid.uuid4()
api = WorkspaceMembersApi()
@ -535,14 +538,18 @@ def test_invite_blocked_by_saas_members_cap(app, bypass_pipeline, monkeypatch):
with _invite_request(app, ws_id, acct_id):
_seed(_auth_ctx(account_id=acct_id))
with pytest.raises(MemberLimitExceeded):
with pytest.raises(Forbidden) as exc_info:
api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
body = exc_info.value.response.json
assert body["code"] == "members.limit_exceeded"
assert "Subscription member limit" in body["message"]
assert body["hint"]
invite_mock.assert_not_called()
def test_invite_blocked_by_ee_workspace_members_license(app, bypass_pipeline, monkeypatch):
"""EE License workspace_members cap → MemberLicenseExceeded (403).
"""EE License workspace_members cap → 403 with `workspace_members.license_exceeded`.
Note: billing.enabled is False (EE without SaaS billing); only the
license cap fires.
@ -577,9 +584,13 @@ def test_invite_blocked_by_ee_workspace_members_license(app, bypass_pipeline, mo
with _invite_request(app, ws_id, acct_id):
_seed(_auth_ctx(account_id=acct_id))
with pytest.raises(MemberLicenseExceeded):
with pytest.raises(Forbidden) as exc_info:
api.post.__wrapped__(api, workspace_id=ws_id, auth_data=_auth_data(acct_id))
body = exc_info.value.response.json
assert body["code"] == "workspace_members.license_exceeded"
assert "license" in body["message"].lower()
assert body["hint"]
invite_mock.assert_not_called()

View File

@ -599,40 +599,3 @@ def test_effective_declared_outputs_passthrough_when_user_declared():
declared = [DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING)]
effective = WorkflowAgentRuntimeRequestBuilder.effective_declared_outputs(declared)
assert list(effective) == declared
def test_mentions_expand_in_soul_and_job_prompts_without_token_leak():
"""ENG-616: slash-menu mention tokens expand to canonical names; node_output
mentions expand to the reference name only (the value stays in the Workflow
context user prompt), and no ``[§…§]`` marker leaks into the request."""
import json
context = _context()
context.snapshot.config_snapshot = AgentSoulConfig(
prompt={"system_prompt": "Careful. Ask [§human:c-1:EMAIL · DAVE§] when unsure."},
model=AgentSoulModelConfig(plugin_id="langgenius/openai", model_provider="openai", model="gpt-test"),
human={"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]},
)
context.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
{
"workflow_prompt": (
"Read [§node_output:previous-node.text:PREV/text§] and produce [§output:summary§]. "
"Unknown [§knowledge:gone:旧手册§] degrades."
),
"previous_node_output_refs": [
{"selector": ["previous-node", "text"], "name": "PREV/text"},
],
"declared_outputs": [{"name": "summary", "type": "string"}],
}
)
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
dumped = result.request.model_dump(mode="json")
assert dumped["composition"]["layers"][0]["config"]["prefix"] == ("Careful. Ask EMAIL · David Hayes when unsure.")
assert dumped["composition"]["layers"][1]["config"]["prefix"] == (
"Read PREV/text and produce summary (string). Unknown 旧手册 degrades."
)
# the value still rides the Workflow context block, not the job prompt
assert "Previous result" in dumped["composition"]["layers"][2]["config"]["user"]
assert "" not in json.dumps(dumped["composition"]["layers"][:3])

View File

@ -1,51 +0,0 @@
"""Unit tests for the shared workflow graph topology helper (ENG-615)."""
from __future__ import annotations
from core.workflow.graph_topology import WorkflowGraphTopology
_GRAPH = {
"nodes": [
{"id": "start"},
{"id": "llm-1"},
{"id": "llm-2"},
{"id": "agent"},
{"id": "end"},
],
"edges": [
{"source": "start", "target": "llm-1"},
{"source": "start", "target": "llm-2"},
{"source": "llm-1", "target": "agent"},
{"source": "llm-2", "target": "agent"},
{"source": "agent", "target": "end"},
# ghost edge: source node was deleted from nodes[]
{"source": "ghost", "target": "agent"},
],
}
def test_upstream_node_ids_collects_all_ancestors_excluding_ghosts():
topology = WorkflowGraphTopology.from_graph(_GRAPH)
assert topology.upstream_node_ids("agent") == {"start", "llm-1", "llm-2"}
def test_upstream_node_ids_differ_per_target_node():
topology = WorkflowGraphTopology.from_graph(_GRAPH)
assert topology.upstream_node_ids("llm-1") == {"start"}
assert topology.upstream_node_ids("end") == {"start", "llm-1", "llm-2", "agent"}
assert topology.upstream_node_ids("start") == set()
def test_is_upstream_kept_for_publish_validation():
topology = WorkflowGraphTopology.from_graph(_GRAPH)
assert topology.is_upstream(source_node_id="start", target_node_id="end")
assert not topology.is_upstream(source_node_id="end", target_node_id="start")
def test_cycle_safe():
graph = {
"nodes": [{"id": "a"}, {"id": "b"}],
"edges": [{"source": "a", "target": "b"}, {"source": "b", "target": "a"}],
}
topology = WorkflowGraphTopology.from_graph(graph)
assert topology.upstream_node_ids("a") == {"b"}

View File

@ -148,7 +148,6 @@ def test_save_workflow_composer_dispatches_save_strategy(monkeypatch, strategy,
tenant_id="tenant-1", app_id="app-1", node_id="node-1", account_id="account-1", payload=payload
)
assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []}
assert result == {"state": "ok"}
assert calls
assert fake_session.commits == 1
@ -190,7 +189,6 @@ def test_save_agent_app_composer_creates_agent_when_missing(monkeypatch):
tenant_id="tenant-1", app_id="app-1", account_id="account-1", payload=payload
)
assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []}
assert result == {"loaded": True}
assert fake_session.added[0].name == "Analyst"
assert fake_session.added[0].active_config_snapshot_id == "version-1"
@ -224,7 +222,6 @@ def test_save_agent_app_composer_updates_current_version(monkeypatch):
tenant_id="tenant-1", app_id="app-1", account_id="account-1", payload=payload
)
assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []}
assert result == {"loaded": True}
assert updated["operation"].value == "save_current_version"
assert fake_session._scalar == []
@ -238,28 +235,12 @@ def test_agent_app_composer_candidates_and_impact(monkeypatch):
]
monkeypatch.setattr(composer_service.db, "session", FakeSession(scalars=[bindings]))
# Candidates assembly is covered in test_composer_candidates.py; here we stub
# the IO loaders and assert the response envelope per variant (ENG-615).
def _no_draft_workflow(**kwargs):
raise ValueError("draft workflow not found")
monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", _no_draft_workflow)
monkeypatch.setattr(AgentComposerService, "_load_agent_app_soul", lambda **kwargs: None)
monkeypatch.setattr(AgentComposerService, "_workspace_dify_tools", lambda **kwargs: [])
workflow_candidates = AgentComposerService.get_workflow_candidates(
tenant_id="tenant-1", app_id="app-1", node_id="node-1", user_id="account-1"
)
agent_app_candidates = AgentComposerService.get_agent_app_candidates(
tenant_id="tenant-1", app_id="app-1", user_id="account-1"
)
workflow_candidates = AgentComposerService.get_workflow_candidates(app_id="app-1")
agent_app_candidates = AgentComposerService.get_agent_app_candidates(app_id="app-1")
impact = AgentComposerService.calculate_impact(tenant_id="tenant-1", current_snapshot_id="version-1")
assert workflow_candidates["variant"] == "workflow"
assert workflow_candidates["allowed_node_job_candidates"]["previous_node_outputs"] == []
assert workflow_candidates["truncated"] is False
assert agent_app_candidates["variant"] == "agent_app"
assert agent_app_candidates["allowed_soul_candidates"]["dify_tools"] == []
assert impact["workflow_node_count"] == 2
assert impact["bindings"][1]["node_id"] == "node-2"
@ -894,27 +875,3 @@ class TestListWorkflowsReferencingAppAgent:
service = AgentRosterService(session)
assert service.list_workflows_referencing_app_agent(tenant_id="tenant-1", app_id="app-1") == []
def test_dataset_rows_filters_malformed_ids(monkeypatch):
"""Mention ids are user-editable text: a non-UUID id must read as missing
(placeholder semantics), never reach the UUID-typed dataset query (E2E 500)."""
captured = {}
def fake_get_datasets_by_ids(ids, tenant_id):
captured["ids"] = ids
return [], 0
import services.dataset_service as dataset_service_module
monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids)
valid = "550e8400-e29b-41d4-a716-446655440000"
rows = AgentComposerService._dataset_rows(tenant_id="tenant-1", dataset_ids=["9999dead-beef", valid])
assert rows == {}
assert captured["ids"] == [valid]
# all-malformed input never touches the DB
captured.clear()
assert AgentComposerService._dataset_rows(tenant_id="tenant-1", dataset_ids=["nope"]) == {}
assert captured == {}

View File

@ -1,204 +0,0 @@
"""Unit tests for slash-menu candidates assembly (ENG-615)."""
from __future__ import annotations
from types import SimpleNamespace
from fields.agent_fields import AgentComposerCandidatesResponse
from models.agent_config_entities import AgentSoulConfig, DeclaredOutputConfig, DeclaredOutputType
from services.agent.composer_candidates import (
MAX_CANDIDATES_PER_LIST,
previous_node_output_candidates,
soul_candidates,
)
_GRAPH = {
"nodes": [
{
"id": "start-1",
"data": {
"type": "start",
"title": "START",
"variables": [{"variable": "tenders", "type": "file-list"}],
},
},
{"id": "llm-1", "data": {"type": "llm", "title": "LLM"}},
{"id": "agent-up", "data": {"type": "agent", "version": "2", "title": "Upstream Agent"}},
{"id": "agent-target", "data": {"type": "agent", "version": "2", "title": "Target Agent"}},
{"id": "end", "data": {"type": "end", "title": "END"}},
],
"edges": [
{"source": "start-1", "target": "llm-1"},
{"source": "llm-1", "target": "agent-up"},
{"source": "agent-up", "target": "agent-target"},
{"source": "agent-target", "target": "end"},
],
}
def _declared_loader(nid: str) -> list[DeclaredOutputConfig] | None:
if nid == "agent-up":
return [DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING)]
return None
def _draft_vars(nid: str) -> list[tuple[str, str | None]]:
if nid == "llm-1":
return [("text", "string")]
return []
def _collect(node_id: str, *, system_vars=()):
entries, truncated = previous_node_output_candidates(
graph=_GRAPH,
node_id=node_id,
declared_outputs_loader=_declared_loader,
draft_variables_loader=_draft_vars,
system_variables_loader=lambda: list(system_vars),
)
return entries, truncated
def test_upstream_outputs_follow_inspector_semantics():
entries, truncated = _collect("agent-target", system_vars=[("query", "string")])
assert truncated is False
by_node = {}
for entry in entries:
by_node.setdefault(entry["node_id"], []).append(entry)
# sys vars ride as a pseudo node, run-derived
assert by_node["sys"][0]["selector"] == ["sys", "query"]
assert by_node["sys"][0]["inferred"] is True
# start variables are static graph facts
start = by_node["start-1"][0]
assert start["selector"] == ["start-1", "tenders"]
assert start["name"] == "START/tenders"
assert start["inferred"] is False
assert start["value_type"] == "file-list"
# agent v2 upstream node uses its declared outputs
agent = by_node["agent-up"][0]
assert agent["output"] == "summary"
assert agent["value_type"] == "string"
assert agent["inferred"] is False
# other kinds fall back to draft variables (inferred)
llm = by_node["llm-1"][0]
assert llm["output"] == "text"
assert llm["inferred"] is True
# the target node itself and downstream nodes never appear
assert "agent-target" not in by_node
assert "end" not in by_node
def test_results_differ_per_node_id():
entries_target, _ = _collect("agent-target")
entries_llm, _ = _collect("llm-1")
assert {e["node_id"] for e in entries_target} == {"start-1", "llm-1", "agent-up"}
assert {e["node_id"] for e in entries_llm} == {"start-1"}
def test_previous_outputs_capped_and_flagged():
graph = {
"nodes": [{"id": "start-1", "data": {"type": "start", "title": "S", "variables": []}}, {"id": "t"}],
"edges": [{"source": "start-1", "target": "t"}],
}
many: list[tuple[str, str | None]] = [(f"v{i}", "string") for i in range(MAX_CANDIDATES_PER_LIST + 5)]
entries, truncated = previous_node_output_candidates(
graph=graph,
node_id="t",
declared_outputs_loader=lambda nid: None,
draft_variables_loader=lambda nid: [],
system_variables_loader=lambda: many,
)
assert len(entries) == MAX_CANDIDATES_PER_LIST
assert truncated is True
def _soul() -> AgentSoulConfig:
return AgentSoulConfig.model_validate(
{
"skills_files": {
"skills": [{"id": "sk-1", "name": "tender-analyzer"}],
"files": [{"id": "f-1", "name": "qna_report.pdf"}],
},
"tools": {
"cli_tools": [{"name": "ffmpeg"}, {"name": "disabled-one", "enabled": False}],
},
"knowledge": {"datasets": [{"id": "ds-1", "name": "旧名"}, {"id": "ds-gone", "name": "已删"}]},
"human": {"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]},
}
)
def test_soul_candidates_lists_configured_items_only():
lists, truncated = soul_candidates(
agent_soul=_soul(),
dataset_lookup=lambda ids: {"ds-1": SimpleNamespace(name="产品手册", description="desc")},
workspace_tools_loader=lambda: [
{"id": "tavily/tavily_search", "name": "tavily_search", "provider": "tavily", "plugin_id": "lg/tavily"}
],
)
assert truncated is False
assert [item["kind"] for item in lists["skills_files"]] == ["skill", "file"]
assert [item["name"] for item in lists["cli_tools"]] == ["ffmpeg"]
# enriched from DB; dangling dataset kept with missing flag (placeholder, 0522)
knowledge = {item["id"]: item for item in lists["knowledge_datasets"]}
assert knowledge["ds-1"]["name"] == "产品手册"
assert knowledge["ds-1"]["missing"] is False
assert knowledge["ds-gone"]["missing"] is True
assert knowledge["ds-gone"]["name"] == "已删"
assert lists["human_contacts"][0]["id"] == "c-1"
assert lists["dify_tools"][0]["id"] == "tavily/tavily_search"
def test_candidates_response_preserves_skill_and_file_candidate_shapes():
response = AgentComposerCandidatesResponse.model_validate(
{
"variant": "agent_app",
"allowed_node_job_candidates": {},
"allowed_soul_candidates": {
"skills_files": [
{"kind": "skill", "id": "sk-1", "name": "tender-analyzer", "path": "skills/tender.md"},
{
"kind": "file",
"id": "f-1",
"name": "qna_report.pdf",
"transfer_method": "local_file",
"reference": "upload-1",
"url": "https://files.example/qna_report.pdf",
},
]
},
"capabilities": {"human_roster_available": False},
}
).model_dump(mode="json")
skill, file = response["allowed_soul_candidates"]["skills_files"]
assert skill["kind"] == "skill"
assert skill["path"] == "skills/tender.md"
assert file["kind"] == "file"
assert file["transfer_method"] == "local_file"
assert file["reference"] == "upload-1"
assert file["url"] == "https://files.example/qna_report.pdf"
def test_soul_candidates_empty_config_yields_empty_lists():
lists, truncated = soul_candidates(
agent_soul=None,
dataset_lookup=lambda ids: {},
workspace_tools_loader=lambda: [],
)
assert truncated is False
assert all(value == [] for value in lists.values())
def test_soul_candidates_caps_lists():
lists, truncated = soul_candidates(
agent_soul=None,
dataset_lookup=lambda ids: {},
workspace_tools_loader=lambda: [{"id": str(i)} for i in range(MAX_CANDIDATES_PER_LIST + 1)],
)
assert len(lists["dify_tools"]) == MAX_CANDIDATES_PER_LIST
assert truncated is True

View File

@ -1,188 +0,0 @@
"""Composer save/validate mention rules (ENG-616 §2.4 allowlists)."""
from __future__ import annotations
import pytest
from services.agent.composer_validator import ComposerConfigValidator
from services.agent.errors import InvalidComposerConfigError
from services.entities.agent_entities import ComposerSavePayload
def _soul_payload(system_prompt: str) -> ComposerSavePayload:
return ComposerSavePayload.model_validate(
{
"variant": "agent_app",
"agent_soul": {"prompt": {"system_prompt": system_prompt}},
"save_strategy": "save_to_current_version",
}
)
def _node_job_payload(workflow_prompt: str) -> ComposerSavePayload:
return ComposerSavePayload.model_validate(
{
"variant": "workflow",
"node_job": {"workflow_prompt": workflow_prompt},
"save_strategy": "node_job_only",
}
)
def test_soul_prompt_accepts_soul_kinds():
payload = _soul_payload("Use [§skill:s1§] [§file:f1§] [§tool:p/t§] [§cli_tool:c§] [§knowledge:k1§] [§human:h1§]")
ComposerConfigValidator.validate_save_payload(payload)
def test_soul_prompt_rejects_node_output_mention():
with pytest.raises(InvalidComposerConfigError, match="mention_kind_not_allowed"):
ComposerConfigValidator.validate_save_payload(_soul_payload("Read [§node_output:n1.text§]"))
def test_soul_prompt_rejects_declared_output_mention():
with pytest.raises(InvalidComposerConfigError, match="mention_kind_not_allowed"):
ComposerConfigValidator.validate_save_payload(_soul_payload("Produce [§output:report§]"))
def test_node_job_prompt_accepts_job_kinds():
payload = _node_job_payload("Read [§node_output:n1.text:START/text§], produce [§output:report§], ask [§human:h1§]")
ComposerConfigValidator.validate_save_payload(payload)
@pytest.mark.parametrize("token", ["[§skill:s1§]", "[§tool:p/t§]", "[§cli_tool:c§]", "[§knowledge:k1§]"])
def test_node_job_prompt_rejects_soul_only_kinds(token: str):
with pytest.raises(InvalidComposerConfigError, match="mention_kind_not_allowed"):
ComposerConfigValidator.validate_save_payload(_node_job_payload(f"Use {token}"))
def test_mention_limit_enforced():
prompt = " ".join(f"[§human:h{i}§]" for i in range(201))
with pytest.raises(InvalidComposerConfigError, match="mention_limit_exceeded"):
ComposerConfigValidator.validate_save_payload(_soul_payload(prompt))
def test_prompt_without_mentions_still_passes():
ComposerConfigValidator.validate_save_payload(_soul_payload("plain prompt, {{var}} and {{#context#}} untouched"))
# ── ENG-617: human must be referenced (hard) ─────────────────────────────────
def _soul_payload_with_human(system_prompt: str) -> ComposerSavePayload:
return ComposerSavePayload.model_validate(
{
"variant": "agent_app",
"agent_soul": {
"prompt": {"system_prompt": system_prompt},
"human": {
"contacts": [{"id": "c-1", "name": "David Hayes", "email": "david@acme.com", "channel": "email"}]
},
},
"save_strategy": "save_to_current_version",
}
)
def test_configured_human_without_mention_is_rejected():
with pytest.raises(InvalidComposerConfigError, match="human_involvement_not_referenced"):
ComposerConfigValidator.validate_save_payload(_soul_payload_with_human("no human reference here"))
def test_configured_human_referenced_by_id_passes():
ComposerConfigValidator.validate_save_payload(_soul_payload_with_human("ask [§human:c-1§] when unsure"))
def test_configured_human_referenced_by_email_alias_passes():
ComposerConfigValidator.validate_save_payload(_soul_payload_with_human("ask [§human:david@acme.com§]"))
def test_node_job_human_must_be_referenced_too():
payload = ComposerSavePayload.model_validate(
{
"variant": "workflow",
"node_job": {
"workflow_prompt": "do the work",
"human_contacts": [{"id": "c-2", "name": "Reviewer"}],
},
"save_strategy": "node_job_only",
}
)
with pytest.raises(InvalidComposerConfigError, match="human_involvement_not_referenced"):
ComposerConfigValidator.validate_save_payload(payload)
payload.node_job.workflow_prompt = "escalate to [§human:c-2§]"
ComposerConfigValidator.validate_save_payload(payload)
def test_identity_less_human_contact_is_skipped():
payload = ComposerSavePayload.model_validate(
{
"variant": "agent_app",
"agent_soul": {
"prompt": {"system_prompt": "plain"},
"human": {"contacts": [{"channel": "email"}]},
},
"save_strategy": "save_to_current_version",
}
)
ComposerConfigValidator.validate_save_payload(payload)
# ── ENG-617: soft findings ───────────────────────────────────────────────────
def _findings(payload: ComposerSavePayload, **kwargs):
return ComposerConfigValidator.collect_soft_findings(payload, **kwargs)
def test_dangling_knowledge_mention_becomes_placeholder_with_label():
payload = _soul_payload("ground in [§knowledge:gone-1:旧产品手册§]")
findings = _findings(payload)
assert findings["knowledge_retrieval_placeholder"] == [{"id": "gone-1", "placeholder_name": "旧产品手册"}]
assert findings["warnings"] == []
def test_dangling_knowledge_without_label_gets_fallback_name():
findings = _findings(_soul_payload("see [§knowledge:deadbeef-cafe§]"))
assert findings["knowledge_retrieval_placeholder"] == [
{"id": "deadbeef-cafe", "placeholder_name": "Knowledge deadbeef"}
]
def test_configured_but_deleted_dataset_surfaces_as_placeholder():
payload = ComposerSavePayload.model_validate(
{
"variant": "agent_app",
"agent_soul": {
"prompt": {"system_prompt": "see [§knowledge:ds-1:产品手册§]"},
"knowledge": {"datasets": [{"id": "ds-1", "name": "产品手册"}]},
},
"save_strategy": "save_to_current_version",
}
)
# configured + DB row exists -> clean
assert _findings(payload, existing_dataset_ids={"ds-1"})["knowledge_retrieval_placeholder"] == []
# configured but deleted in DB -> placeholder
assert _findings(payload, existing_dataset_ids=set())["knowledge_retrieval_placeholder"] == [
{"id": "ds-1", "placeholder_name": "产品手册"}
]
def test_unresolved_non_knowledge_mentions_warn_target_missing():
findings = _findings(_soul_payload("use [§skill:nope:Ghost Skill§] and [§human:missing§]"))
codes = [(w["code"], w["kind"]) for w in findings["warnings"]]
assert ("mention_target_missing", "skill") in codes
assert ("mention_target_missing", "human") in codes
assert findings["knowledge_retrieval_placeholder"] == []
def test_malformed_marker_warns_but_does_not_block():
payload = _soul_payload("hello [§wat:x:y§] world")
ComposerConfigValidator.validate_save_payload(payload) # no hard error
findings = _findings(payload)
assert [w["code"] for w in findings["warnings"]] == ["mention_malformed"]
def test_clean_prompt_yields_empty_findings():
findings = _findings(_soul_payload("plain prompt with {{#context#}} legacy form"))
assert findings == {"warnings": [], "knowledge_retrieval_placeholder": []}

View File

@ -1,179 +0,0 @@
"""Unit tests for the prompt mention contract (ENG-616).
Token form: ``[§<kind>:<id>[:<label>]§]``. Mentions are pointers into the Agent
config lists; expansion replaces them with canonical names and the scrub pass
guarantees no mention-shaped marker survives to the model.
"""
from __future__ import annotations
import pytest
from models.agent_config_entities import AgentSoulConfig, WorkflowNodeJobConfig
from services.agent.prompt_mentions import (
MAX_MENTION_FIELD_LENGTH,
NODE_JOB_PROMPT_ALLOWED_KINDS,
SOUL_PROMPT_ALLOWED_KINDS,
MentionKind,
build_node_job_mention_resolver,
build_soul_mention_resolver,
expand_prompt_mentions,
parse_prompt_mentions,
scrub_mention_markers,
)
# ── parse ─────────────────────────────────────────────────────────────────────
def test_parse_extracts_kind_id_and_optional_label():
prompt = "Use [§skill:abc-1:tender-analyzer§] then ask [§human:c-1§]."
mentions = parse_prompt_mentions(prompt)
assert [(m.kind, m.ref_id, m.label) for m in mentions] == [
(MentionKind.SKILL, "abc-1", "tender-analyzer"),
(MentionKind.HUMAN, "c-1", None),
]
assert prompt[mentions[0].start : mentions[0].end] == mentions[0].raw
def test_parse_supports_ids_with_slash_and_dot():
mentions = parse_prompt_mentions("[§tool:langgenius/tavily/tavily_search:tavily§] [§node_output:node-1.tenders§]")
assert mentions[0].ref_id == "langgenius/tavily/tavily_search"
assert mentions[1].ref_id == "node-1.tenders"
def test_parse_ignores_legacy_template_forms_and_unknown_kinds():
prompt = "{{var}} {{#context#}} {{#sys.query#}} [§bogus_kind:x§]"
assert parse_prompt_mentions(prompt) == []
def test_parse_skips_oversized_id_or_label():
long_id = "x" * (MAX_MENTION_FIELD_LENGTH + 1)
assert parse_prompt_mentions(f"[§skill:{long_id}§]") == []
# ── expand + scrub ────────────────────────────────────────────────────────────
def test_expand_uses_resolver_and_degrades_unresolved_to_label_then_id():
prompt = "A [§skill:s1:Skill One§] B [§human:h1:EMAIL · DAVE§] C [§knowledge:k1§]"
def resolver(mention):
return "resolved-skill" if mention.kind == MentionKind.SKILL else None
expanded = expand_prompt_mentions(prompt, resolver)
assert expanded == "A resolved-skill B EMAIL · DAVE C k1"
assert "" not in expanded
def test_expand_scrubs_unknown_kind_tokens_but_keeps_legacy_forms():
prompt = "x [§wat:id-1:Label§] y {{#context#}} z {{#node.var#}}"
expanded = expand_prompt_mentions(prompt, lambda m: None)
# unknown mention-shaped token degraded to its label; legacy forms untouched
assert expanded == "x Label y {{#context#}} z {{#node.var#}}"
def test_scrub_degrades_colon_tokens_without_label_to_id_part():
assert scrub_mention_markers("see [§weird_kind:some-id§]") == "see some-id"
def test_expand_empty_prompt_is_noop():
assert expand_prompt_mentions("", lambda m: "x") == ""
# ── soul resolver ─────────────────────────────────────────────────────────────
@pytest.fixture
def soul() -> AgentSoulConfig:
return AgentSoulConfig.model_validate(
{
"skills_files": {
"skills": [{"id": "sk-1", "name": "tender-analyzer"}],
"files": [{"id": "f-1", "name": "qna_report.pdf"}],
},
"tools": {
"dify_tools": [
{
"plugin_id": "langgenius/tavily",
"provider": "tavily",
"tool_name": "tavily_search",
"credential_type": "unauthorized",
},
],
"cli_tools": [{"name": "ffmpeg"}],
},
"knowledge": {"datasets": [{"id": "ds-1", "name": "产品手册"}]},
"human": {"contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}]},
}
)
def test_soul_resolver_resolves_each_kind(soul: AgentSoulConfig):
resolver = build_soul_mention_resolver(soul)
prompt = (
"Use [§skill:sk-1§] with [§file:f-1§], search via "
"[§tool:tavily/tavily_search:tavily§], run [§cli_tool:ffmpeg§], "
"ground in [§knowledge:ds-1§], ask [§human:c-1§]."
)
expanded = expand_prompt_mentions(prompt, resolver)
assert expanded == (
"Use tender-analyzer with qna_report.pdf, search via tavily_search, "
"run ffmpeg, ground in 产品手册, ask EMAIL · David Hayes."
)
def test_soul_resolver_unknown_ids_degrade(soul: AgentSoulConfig):
expanded = expand_prompt_mentions("[§knowledge:missing:旧产品手册§]", build_soul_mention_resolver(soul))
assert expanded == "旧产品手册"
# ── node-job resolver ─────────────────────────────────────────────────────────
@pytest.fixture
def node_job() -> WorkflowNodeJobConfig:
return WorkflowNodeJobConfig.model_validate(
{
"workflow_prompt": "",
"previous_node_output_refs": [{"selector": ["start-1", "tenders"], "name": "START/tenders"}],
# declared output names are JSON-schema-friendly identifiers (no dots)
"declared_outputs": [{"name": "qna_report", "type": "file"}],
"human_contacts": [{"id": "c-1", "name": "David Hayes", "channel": "email"}],
}
)
def test_node_job_resolver_resolves_each_kind(node_job: WorkflowNodeJobConfig):
resolver = build_node_job_mention_resolver(node_job)
prompt = "Read [§node_output:start-1.tenders§] and produce [§output:qna_report§]; if unsure contact [§human:c-1§]."
expanded = expand_prompt_mentions(prompt, resolver)
assert expanded == ("Read START/tenders and produce qna_report (file); if unsure contact EMAIL · David Hayes.")
def test_node_job_resolver_matches_ref_by_node_id_and_output_fields():
node_job = WorkflowNodeJobConfig.model_validate(
{"previous_node_output_refs": [{"node_id": "n-2", "output": "text"}]}
)
expanded = expand_prompt_mentions("[§node_output:n-2.text:LLM/text§]", build_node_job_mention_resolver(node_job))
# ref has no display name -> degrade to the mention label
assert expanded == "LLM/text"
# ── allowlists ────────────────────────────────────────────────────────────────
def test_per_surface_allowlists_match_design():
assert {
MentionKind.SKILL,
MentionKind.FILE,
MentionKind.TOOL,
MentionKind.CLI_TOOL,
MentionKind.KNOWLEDGE,
MentionKind.HUMAN,
} == SOUL_PROMPT_ALLOWED_KINDS
assert {MentionKind.NODE_OUTPUT, MentionKind.OUTPUT, MentionKind.HUMAN} == NODE_JOB_PROMPT_ALLOWED_KINDS

View File

@ -1,4 +1,3 @@
import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen'
import type { ErrorCodeValue, ExitCodeValue } from './codes'
import type { ErrorEnvelope, PrintableError } from './format'
import { ErrorCode, exitFor } from './codes'
@ -84,7 +83,6 @@ type HttpClientErrorOptions = BaseErrorOptions & {
readonly method?: string
readonly url?: string
readonly rawResponse?: string
readonly serverError?: ErrorBody
}
export class HttpClientError extends BaseError {
@ -92,7 +90,6 @@ export class HttpClientError extends BaseError {
readonly method?: string
readonly url?: string
readonly rawResponse?: string
readonly serverError?: ErrorBody
constructor(opts: HttpClientErrorOptions) {
super(opts)
@ -100,7 +97,6 @@ export class HttpClientError extends BaseError {
this.method = opts.method
this.url = opts.url
this.rawResponse = opts.rawResponse
this.serverError = opts.serverError
}
override toEnvelope(): ErrorEnvelope {
@ -113,8 +109,6 @@ export class HttpClientError extends BaseError {
envelope.error.url = this.url
if (this.rawResponse !== undefined)
envelope.error.raw_response = this.rawResponse
if (this.serverError !== undefined)
envelope.error.server = this.serverError
return envelope
}
@ -125,7 +119,6 @@ export class HttpClientError extends BaseError {
method: this.method,
url: this.url,
rawResponse: this.rawResponse,
serverError: this.serverError,
}
}
@ -152,8 +145,4 @@ export class HttpClientError extends BaseError {
}
return new HttpClientError({ ...this.snapshot(), rawResponse })
}
withServerError(serverError: ErrorBody): HttpClientError {
return new HttpClientError({ ...this.snapshot(), serverError })
}
}

View File

@ -1,101 +0,0 @@
import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen'
import { describe, expect, it } from 'vitest'
import { HttpClientError } from './base'
import { ErrorCode } from './codes'
import { formatErrorForCli } from './format'
type ValidationErrorOverrides = {
readonly cliHint?: string
readonly serverHint?: string
readonly details?: ErrorBody['details']
}
function validationError(overrides: ValidationErrorOverrides = {}): HttpClientError {
const details
= overrides.details
?? [
{ type: 'int_parsing', loc: ['page'], msg: 'must be >= 1' },
{ type: 'missing', loc: ['inputs', 'query'], msg: 'field required' },
]
return new HttpClientError({
code: ErrorCode.Server4xxOther,
message: 'Request validation failed',
httpStatus: 422,
hint: overrides.cliHint,
serverError: {
code: 'invalid_param',
message: 'Request validation failed',
status: 422,
hint: overrides.serverHint,
details,
},
})
}
describe('formatErrorForCli — human', () => {
it('prints server code, message, and details without verbose', () => {
const out = formatErrorForCli(validationError({ serverHint: 'check the page parameter' }), { isErrTTY: false })
expect(out).toContain('invalid_param: Request validation failed')
expect(out).toContain('- page: must be >= 1 (int_parsing)')
expect(out).toContain('- inputs.query: field required (missing)')
expect(out).toContain('check the page parameter')
expect(out).not.toContain('raw_response')
})
it('falls back to cli code when no server code', () => {
const err = new HttpClientError({ code: ErrorCode.Server5xx, message: 'server error (HTTP 502)', httpStatus: 502 })
const out = formatErrorForCli(err, { isErrTTY: false })
expect(out).toContain('server_5xx: server error (HTTP 502)')
})
it('server hint wins over cli hint; cli hint fills when server sent none', () => {
const withCliHint = validationError({ cliHint: 'cli fallback hint', serverHint: 'check the page parameter', details: [] })
expect(formatErrorForCli(withCliHint, { isErrTTY: false })).toContain('check the page parameter')
expect(formatErrorForCli(withCliHint, { isErrTTY: false })).not.toContain('cli fallback hint')
// no server hint → cli hint shown
const noServerHint = new HttpClientError({
code: ErrorCode.AuthExpired,
message: 'session expired',
hint: 'run difyctl auth login',
})
expect(formatErrorForCli(noServerHint, { isErrTTY: false })).toContain('run difyctl auth login')
})
it('omits the loc prefix when a detail has no loc', () => {
const out = formatErrorForCli(
validationError({ details: [{ type: 'invalid', loc: [], msg: 'body required' }] }),
{ isErrTTY: false },
)
expect(out).toContain('- body required (invalid)')
expect(out).not.toContain('- : body required')
})
it('renders request and http_status lines', () => {
const err = new HttpClientError({
code: ErrorCode.Server5xx,
message: 'upstream boom',
httpStatus: 502,
method: 'GET',
url: 'https://api.dify.ai/v1/me',
})
const out = formatErrorForCli(err, { isErrTTY: false })
expect(out).toContain('request: GET https://api.dify.ai/v1/me')
expect(out).toContain('http_status: 502')
})
})
describe('formatErrorForCli — json', () => {
it('envelope nests the whole server error', () => {
const out = JSON.parse(formatErrorForCli(validationError(), { format: 'json' }))
expect(out.error.server.code).toBe('invalid_param')
expect(out.error.server.details).toHaveLength(2)
expect(out.error.code).toBe('server_4xx_other')
})
})

View File

@ -1,4 +1,3 @@
import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen'
import { isVerbose } from '@/framework/context'
import { redactBearer } from '@/http/sanitize'
import { colorEnabled, colorScheme } from '@/sys/io/color'
@ -17,7 +16,6 @@ export type ErrorEnvelope = {
method?: string
url?: string
raw_response?: string
server?: ErrorBody
}
}
@ -47,16 +45,9 @@ function renderEnvelope(env: ErrorEnvelope): string {
function renderHuman(env: ErrorEnvelope, isErrTTY: boolean): string {
const cs = colorScheme(colorEnabled(isErrTTY))
const e = env.error
const server = e.server
const headerCode = server?.code ?? e.code
const lines: string[] = [`${headerCode}: ${e.message}`]
for (const d of server?.details ?? []) {
const loc = (d.loc ?? []).join('.')
lines.push(` - ${loc ? `${loc}: ` : ''}${d.msg} (${d.type})`)
}
const hint = server?.hint ?? e.hint
if (hint !== undefined && hint !== null)
lines.push(`${cs.magenta('hint:')} ${cs.cyan(hint)}`)
const lines: string[] = [`${e.code}: ${e.message}`]
if (e.hint !== undefined)
lines.push(`${cs.magenta('hint:')} ${cs.cyan(e.hint)}`)
if (e.method !== undefined && e.url !== undefined)
lines.push(`request: ${e.method} ${e.url}`)
if (e.http_status !== undefined)

View File

@ -1,73 +0,0 @@
import type { HttpClientError } from '@/errors/base'
import { describe, expect, it } from 'vitest'
import { ErrorCode } from '@/errors/codes'
import { classifyResponse } from './error-mapper'
function res(status: number, body: unknown): Response {
return new Response(typeof body === 'string' ? body : JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
})
}
const req = new Request('https://dify.test/openapi/v1/apps')
function classified(status: number, body: unknown): Promise<HttpClientError> {
return classifyResponse(req, res(status, body))
}
describe('classifyResponse — canonical ErrorBody', () => {
it('attaches the parsed body whole as serverError', async () => {
const body = {
code: 'invalid_param',
message: 'Request validation failed',
status: 422,
hint: 'check the page parameter',
details: [{ type: 'int_parsing', loc: ['page'], msg: 'must be >= 1' }],
}
const err = await classified(422, body)
expect(err.serverError).toEqual(body)
expect(err.message).toBe('Request validation failed')
expect(err.code).toBe(ErrorCode.Server4xxOther)
})
it('401 classifies by status as AuthExpired with CLI login hint', async () => {
const err = await classified(401, {
code: 'unauthorized',
message: 'session expired or revoked',
status: 401,
})
expect(err.code).toBe(ErrorCode.AuthExpired)
expect(err.hint).toBe('run \'difyctl auth login\' to sign in again')
})
it('unknown future server code is data, not behavior — status bucket decides', async () => {
const err = await classified(409, {
code: 'some_future_code',
message: 'nope',
status: 409,
})
expect(err.code).toBe(ErrorCode.Server4xxOther)
expect(err.serverError?.code).toBe('some_future_code')
})
})
describe('classifyResponse — non-conforming bodies (no fallback by design)', () => {
it('non-JSON body yields no serverError, classification by status', async () => {
const err = await classified(502, '<html>bad gateway</html>')
expect(err.code).toBe(ErrorCode.Server5xx)
expect(err.serverError).toBeUndefined()
})
it('RFC 8628 string error field yields no serverError and a generic message', async () => {
const err = await classified(400, { error: 'slow_down' })
expect(err.message).toBe('request failed (HTTP 400)')
expect(err.serverError).toBeUndefined()
})
})

View File

@ -1,85 +1,70 @@
import type { ErrorBody } from '@dify/contracts/api/openapi/types.gen'
import type { ErrorCodeValue } from '@/errors/codes'
import { zErrorBody } from '@dify/contracts/api/openapi/zod.gen'
import { BaseError, HttpClientError, newError } from '@/errors/base'
import { ErrorCode } from '@/errors/codes'
import { redactBearer } from './sanitize'
const AUTH_EXPIRED_MESSAGE = 'session expired or revoked'
const AUTH_LOGIN_HINT = 'run \'difyctl auth login\' to sign in again'
// How one HTTP status bucket classifies: CLI code, message fallback when the
// body is not a canonical ErrorBody, optional CLI hint, raw-body retention.
type StatusClass = {
readonly code: ErrorCodeValue
readonly fallbackMessage: (status: number) => string
readonly hint?: string
readonly includeRaw: boolean
type WireFields = {
code?: string
message?: string
hint?: string
}
const AUTH_EXPIRED_CLASS: StatusClass = {
code: ErrorCode.AuthExpired,
fallbackMessage: () => AUTH_EXPIRED_MESSAGE,
hint: AUTH_LOGIN_HINT,
includeRaw: false,
type WireEnvelope = WireFields & {
error?: WireFields
}
const SERVER_5XX_CLASS: StatusClass = {
code: ErrorCode.Server5xx,
fallbackMessage: status => `server error (HTTP ${status})`,
includeRaw: true,
}
const SERVER_4XX_CLASS: StatusClass = {
code: ErrorCode.Server4xxOther,
fallbackMessage: status => `request failed (HTTP ${status})`,
includeRaw: true,
}
function statusClass(status: number): StatusClass {
if (status === 401)
return AUTH_EXPIRED_CLASS
if (status >= 500)
return SERVER_5XX_CLASS
return SERVER_4XX_CLASS
}
function parseServerError(raw: string): ErrorBody | undefined {
if (raw === '')
return undefined
let parsed: unknown
try {
parsed = JSON.parse(raw)
}
catch {
return undefined
}
const result = zErrorBody.safeParse(parsed)
return result.success ? result.data : undefined
}
export async function classifyResponse(request: Request, response: Response): Promise<HttpClientError> {
async function readBody(response: Response): Promise<{ raw: string, parsed?: WireEnvelope }> {
let raw = ''
try {
raw = await response.clone().text()
raw = await response.text()
}
catch {
// ignore read errors; raw stays ''
return { raw: '' }
}
if (raw === '')
return { raw }
try {
return { raw, parsed: JSON.parse(raw) as WireEnvelope }
}
catch {
return { raw }
}
}
export async function classifyResponse(request: Request, response: Response): Promise<BaseError> {
const { parsed, raw } = await readBody(response.clone())
const wire: WireFields = parsed?.error ?? parsed ?? {}
const status = response.status
const url = redactBearer(response.url || request.url)
const method = request.method
if (status === 401) {
return HttpClientError.from(newError(
ErrorCode.AuthExpired,
wire.message ?? 'session expired or revoked',
))
.withHint(wire.hint ?? 'run \'difyctl auth login\' to sign in again')
.withHttpStatus(status)
.withRequest(method, url)
}
const serverError = parseServerError(raw)
const status = response.status
const c = statusClass(status)
return new HttpClientError({
code: c.code,
message: serverError?.message ?? c.fallbackMessage(status),
hint: c.hint,
httpStatus: status,
method: request.method,
url: redactBearer(response.url || request.url),
rawResponse: c.includeRaw && raw !== '' ? raw : undefined,
serverError,
})
if (status >= 500) {
return HttpClientError.from(newError(
ErrorCode.Server5xx,
wire.message ?? `server error (HTTP ${status})`,
))
.withHttpStatus(status)
.withRequest(method, url)
.withRawResponse(raw)
}
const err = HttpClientError.from(newError(
ErrorCode.Server4xxOther,
wire.message ?? `request failed (HTTP ${status})`,
))
.withHttpStatus(status)
.withRequest(method, url)
.withRawResponse(raw)
return wire.hint !== undefined ? err.withHint(wire.hint) : err
}
export function classifyTransportError(err: unknown): BaseError {

View File

@ -1,5 +1,4 @@
import type { StubServer } from '@test/fixtures/stub-server'
import type { HttpClientError } from '@/errors/base'
import { jsonResponder, startStubServer } from '@test/fixtures/stub-server'
import { afterEach, describe, expect, it } from 'vitest'
import { isHttpClientError } from '@/errors/base'
@ -34,49 +33,66 @@ describe('createOpenApiClient error mapping', () => {
await stub?.stop()
})
async function classifiedError(status: number, body: unknown): Promise<HttpClientError> {
stub = await startStubServer(cap => jsonResponder(status, body, cap))
it('recovers Dify message + hint from a top-level 4xx envelope', async () => {
stub = await startStubServer(cap => jsonResponder(403, { message: 'no access', hint: 'ask an admin' }, cap))
const orpc = orpcClient(stub.url)
const caught = await catchErr(() => orpc.account.get())
if (!isHttpClientError(caught))
throw new Error(`expected HttpClientError, got: ${String(caught)}`)
return caught
}
it('recovers Dify message from a canonical ErrorBody 4xx response', async () => {
const caught = await classifiedError(403, { code: 'access_denied', message: 'no access', status: 403 })
expect(caught.code).toBe(ErrorCode.Server4xxOther)
expect(caught.httpStatus).toBe(403)
expect(caught.message).toBe('no access')
// Parity with the transport path: the migrated endpoint's error keeps the request
// method/url and the raw body, so formatted errors still print the `request:` line
// and the raw-response dump (not just message/hint).
expect(caught.method).toBe('GET')
expect(caught.url).toContain('/account')
expect(caught.rawResponse).toContain('no access')
expect(isHttpClientError(caught)).toBe(true)
if (isHttpClientError(caught)) {
expect(caught.code).toBe(ErrorCode.Server4xxOther)
expect(caught.httpStatus).toBe(403)
expect(caught.message).toBe('no access')
expect(caught.hint).toBe('ask an admin')
// Parity with the transport path: the migrated endpoint's error keeps the request
// method/url and the raw body, so formatted errors still print the `request:` line
// and the raw-response dump (not just message/hint).
expect(caught.method).toBe('GET')
expect(caught.url).toContain('/account')
expect(caught.rawResponse).toContain('no access')
}
})
it('reads server message from canonical ErrorBody on 401 and keeps the auth code', async () => {
const caught = await classifiedError(401, { code: 'unauthorized', message: 'expired', status: 401 })
it('recovers from a nested { error: { message, hint } } envelope and keeps the auth code on 401', async () => {
stub = await startStubServer(cap => jsonResponder(401, { error: { message: 'expired', hint: 'relogin' } }, cap))
const orpc = orpcClient(stub.url)
expect(caught.code).toBe(ErrorCode.AuthExpired)
expect(caught.httpStatus).toBe(401)
expect(caught.message).toBe('expired')
const caught = await catchErr(() => orpc.account.get())
expect(isHttpClientError(caught)).toBe(true)
if (isHttpClientError(caught)) {
expect(caught.code).toBe(ErrorCode.AuthExpired)
expect(caught.httpStatus).toBe(401)
expect(caught.message).toBe('expired')
expect(caught.hint).toBe('relogin')
}
})
it('uses CLI default auth-login hint for non-conforming 401 body', async () => {
const caught = await classifiedError(401, { error: 'expired' })
it('falls back to the default auth-login hint when the body carries none', async () => {
stub = await startStubServer(cap => jsonResponder(401, { error: 'expired' }, cap))
const orpc = orpcClient(stub.url)
expect(caught.code).toBe(ErrorCode.AuthExpired)
expect(caught.hint).toContain('difyctl auth login')
const caught = await catchErr(() => orpc.account.get())
expect(isHttpClientError(caught)).toBe(true)
if (isHttpClientError(caught)) {
expect(caught.code).toBe(ErrorCode.AuthExpired)
expect(caught.hint).toContain('difyctl auth login')
}
})
it('maps 5xx to Server5xx with message from canonical ErrorBody', async () => {
const caught = await classifiedError(503, { code: 'service_unavailable', message: 'down for maintenance', status: 503 })
it('maps 5xx to Server5xx', async () => {
stub = await startStubServer(cap => jsonResponder(503, { message: 'down for maintenance' }, cap))
const orpc = orpcClient(stub.url)
expect(caught.code).toBe(ErrorCode.Server5xx)
expect(caught.httpStatus).toBe(503)
expect(caught.message).toBe('down for maintenance')
const caught = await catchErr(() => orpc.account.get())
expect(isHttpClientError(caught)).toBe(true)
if (isHttpClientError(caught)) {
expect(caught.code).toBe(ErrorCode.Server5xx)
expect(caught.httpStatus).toBe(503)
expect(caught.message).toBe('down for maintenance')
}
})
})

View File

@ -255,6 +255,11 @@
"count": 1
}
},
"web/app/components/app-sidebar/nav-link/index.tsx": {
"tailwindcss/enforce-consistent-class-order": {
"count": 3
}
},
"web/app/components/app/annotation/add-annotation-modal/edit-item/index.tsx": {
"erasable-syntax-only/enums": {
"count": 1
@ -848,7 +853,7 @@
},
"web/app/components/base/chat/chat/chat-input-area/index.tsx": {
"ts/no-explicit-any": {
"count": 3
"count": 2
}
},
"web/app/components/base/chat/chat/check-input-forms-hooks.ts": {
@ -2361,6 +2366,14 @@
"count": 2
}
},
"web/app/components/explore/try-app/tab.tsx": {
"erasable-syntax-only/enums": {
"count": 1
},
"react-refresh/only-export-components": {
"count": 1
}
},
"web/app/components/goto-anything/actions/commands/command-bus.ts": {
"ts/no-explicit-any": {
"count": 2
@ -3703,6 +3716,17 @@
"count": 7
}
},
"web/app/components/workflow/nodes/_base/components/workflow-panel/tab.tsx": {
"erasable-syntax-only/enums": {
"count": 1
},
"react-refresh/only-export-components": {
"count": 1
},
"ts/no-explicit-any": {
"count": 1
}
},
"web/app/components/workflow/nodes/_base/hooks/use-one-step-run.ts": {
"no-restricted-imports": {
"count": 1

View File

@ -173,7 +173,6 @@ export type AgentAppComposerResponse = {
agent: AgentComposerAgentResponse
agent_soul: AgentSoulConfig
save_options: Array<ComposerSaveStrategy>
validation?: ComposerValidationFindingsResponse
variant: string
}
@ -194,15 +193,12 @@ export type AgentComposerCandidatesResponse = {
allowed_node_job_candidates?: AgentComposerNodeJobCandidatesResponse
allowed_soul_candidates?: AgentComposerSoulCandidatesResponse
capabilities?: ComposerCandidateCapabilities
truncated?: boolean
variant: ComposerVariant
}
export type AgentComposerValidateResponse = {
errors?: Array<string>
knowledge_retrieval_placeholder?: Array<ComposerKnowledgePlaceholderResponse>
result: string
warnings?: Array<ComposerValidationWarningResponse>
}
export type AgentAppFeaturesPayload = {
@ -801,7 +797,6 @@ export type WorkflowAgentComposerResponse = {
node_job: WorkflowNodeJobConfig
save_options: Array<ComposerSaveStrategy>
soul_lock: AgentComposerSoulLockResponse
validation?: ComposerValidationFindingsResponse
variant: string
workflow_id?: string | null
}
@ -1104,11 +1099,6 @@ export type ComposerSaveStrategy
| 'save_to_current_version'
| 'save_to_roster'
export type ComposerValidationFindingsResponse = {
knowledge_retrieval_placeholder?: Array<ComposerKnowledgePlaceholderResponse>
warnings?: Array<ComposerValidationWarningResponse>
}
export type ComposerBindingPayload = {
agent_id?: string | null
binding_type: 'inline_agent' | 'roster_agent'
@ -1143,26 +1133,13 @@ export type AgentComposerSoulCandidatesResponse = {
dify_tools?: Array<AgentComposerDifyToolCandidateResponse>
human_contacts?: Array<AgentHumanContactConfig>
knowledge_datasets?: Array<AgentKnowledgeDatasetConfig>
skills_files?: Array<unknown>
skills_files?: Array<AgentSkillRefConfig>
}
export type ComposerCandidateCapabilities = {
human_roster_available?: boolean
}
export type ComposerKnowledgePlaceholderResponse = {
id: string
placeholder_name: string
}
export type ComposerValidationWarningResponse = {
code: string
id?: string | null
kind?: string | null
message?: string | null
surface?: string | null
}
export type AgentFeatureToggleConfig = {
enabled?: boolean
[key: string]: unknown
@ -1755,31 +1732,15 @@ export type AgentKnowledgeDatasetConfig = {
[key: string]: unknown
}
export type AgentComposerSkillCandidateResponse = {
export type AgentSkillRefConfig = {
description?: string | null
file_id?: string | null
id?: string | null
kind?: string
name?: string | null
path?: string | null
[key: string]: unknown
}
export type AgentComposerFileCandidateResponse = {
file_id?: string | null
id?: string | null
kind?: string
name?: string | null
reference?: string | null
remote_url?: string | null
tenant_id?: string | null
transfer_method?: string | null
type?: string | null
upload_file_id?: string | null
url?: string | null
[key: string]: unknown
}
export type AgentModerationProviderConfig = {
api_based_extension_id?: string | null
inputs_config?: AgentModerationIoConfig
@ -1980,15 +1941,6 @@ export type AgentFileRefConfig = {
[key: string]: unknown
}
export type AgentSkillRefConfig = {
description?: string | null
file_id?: string | null
id?: string | null
name?: string | null
path?: string | null
[key: string]: unknown
}
export type AgentSoulDifyToolConfig = {
credential_ref?: AgentSoulDifyToolCredentialRef
credential_type?: 'api-key' | 'oauth2' | 'unauthorized'

View File

@ -77,6 +77,14 @@ export const zAdvancedChatWorkflowRunPayload = z.object({
query: z.string().optional().default(''),
})
/**
* AgentComposerValidateResponse
*/
export const zAgentComposerValidateResponse = z.object({
errors: z.array(z.string()).optional(),
result: z.string(),
})
/**
* SimpleResultResponse
*/
@ -794,43 +802,6 @@ export const zComposerCandidateCapabilities = z.object({
human_roster_available: z.boolean().optional().default(false),
})
/**
* ComposerKnowledgePlaceholderResponse
*/
export const zComposerKnowledgePlaceholderResponse = z.object({
id: z.string(),
placeholder_name: z.string(),
})
/**
* ComposerValidationWarningResponse
*/
export const zComposerValidationWarningResponse = z.object({
code: z.string(),
id: z.string().nullish(),
kind: z.string().nullish(),
message: z.string().nullish(),
surface: z.string().nullish(),
})
/**
* AgentComposerValidateResponse
*/
export const zAgentComposerValidateResponse = z.object({
errors: z.array(z.string()).optional(),
knowledge_retrieval_placeholder: z.array(zComposerKnowledgePlaceholderResponse).optional(),
result: z.string(),
warnings: z.array(zComposerValidationWarningResponse).optional(),
})
/**
* ComposerValidationFindingsResponse
*/
export const zComposerValidationFindingsResponse = z.object({
knowledge_retrieval_placeholder: z.array(zComposerKnowledgePlaceholderResponse).optional(),
warnings: z.array(zComposerValidationWarningResponse).optional(),
})
/**
* AgentFeatureToggleConfig
*/
@ -1792,34 +1763,16 @@ export const zAgentKnowledgeDatasetConfig = z.object({
})
/**
* AgentComposerSkillCandidateResponse
* AgentSkillRefConfig
*/
export const zAgentComposerSkillCandidateResponse = z.object({
export const zAgentSkillRefConfig = z.object({
description: z.string().nullish(),
file_id: z.string().max(255).nullish(),
id: z.string().max(255).nullish(),
kind: z.string().optional().default('skill'),
name: z.string().max(255).nullish(),
path: z.string().nullish(),
})
/**
* AgentComposerFileCandidateResponse
*/
export const zAgentComposerFileCandidateResponse = z.object({
file_id: z.string().max(255).nullish(),
id: z.string().max(255).nullish(),
kind: z.string().optional().default('file'),
name: z.string().max(255).nullish(),
reference: z.string().max(255).nullish(),
remote_url: z.string().nullish(),
tenant_id: z.string().max(255).nullish(),
transfer_method: z.string().max(64).nullish(),
type: z.string().max(64).nullish(),
upload_file_id: z.string().max(255).nullish(),
url: z.string().nullish(),
})
/**
* SimpleModelConfig
*/
@ -2203,25 +2156,6 @@ export const zAgentFileRefConfig = z.object({
url: z.string().nullish(),
})
/**
* WorkflowNodeJobMetadata
*/
export const zWorkflowNodeJobMetadata = z.object({
agent_soul: z.record(z.string(), z.unknown()).nullish(),
file_refs: z.array(zAgentFileRefConfig).nullish(),
})
/**
* AgentSkillRefConfig
*/
export const zAgentSkillRefConfig = z.object({
description: z.string().nullish(),
file_id: z.string().max(255).nullish(),
id: z.string().max(255).nullish(),
name: z.string().max(255).nullish(),
path: z.string().nullish(),
})
/**
* AgentSoulSkillsFilesConfig
*/
@ -2230,6 +2164,14 @@ export const zAgentSoulSkillsFilesConfig = z.object({
skills: z.array(zAgentSkillRefConfig).optional(),
})
/**
* WorkflowNodeJobMetadata
*/
export const zWorkflowNodeJobMetadata = z.object({
agent_soul: z.record(z.string(), z.unknown()).nullish(),
file_refs: z.array(zAgentFileRefConfig).nullish(),
})
/**
* AgentCliToolAuthorizationStatus
*
@ -2328,7 +2270,7 @@ export const zAgentComposerSoulCandidatesResponse = z.object({
dify_tools: z.array(zAgentComposerDifyToolCandidateResponse).optional(),
human_contacts: z.array(zAgentHumanContactConfig).optional(),
knowledge_datasets: z.array(zAgentKnowledgeDatasetConfig).optional(),
skills_files: z.array(z.unknown()).optional(),
skills_files: z.array(zAgentSkillRefConfig).optional(),
})
/**
@ -2338,7 +2280,6 @@ export const zAgentComposerCandidatesResponse = z.object({
allowed_node_job_candidates: zAgentComposerNodeJobCandidatesResponse.optional(),
allowed_soul_candidates: zAgentComposerSoulCandidatesResponse.optional(),
capabilities: zComposerCandidateCapabilities.optional(),
truncated: z.boolean().optional().default(false),
variant: zComposerVariant,
})
@ -2616,7 +2557,6 @@ export const zAgentAppComposerResponse = z.object({
agent: zAgentComposerAgentResponse,
agent_soul: zAgentSoulConfig,
save_options: z.array(zComposerSaveStrategy),
validation: zComposerValidationFindingsResponse.optional(),
variant: z.string(),
})
@ -2651,7 +2591,6 @@ export const zWorkflowAgentComposerResponse = z.object({
node_job: zWorkflowNodeJobConfig,
save_options: z.array(zComposerSaveStrategy),
soul_lock: zAgentComposerSoulLockResponse,
validation: zComposerValidationFindingsResponse.optional(),
variant: z.string(),
workflow_id: z.string().nullish(),
})

View File

@ -168,20 +168,6 @@ export type DevicePollRequest = {
device_code: string
}
export type ErrorBody = {
code: string
details?: Array<ErrorDetail> | null
hint?: string | null
message: string
status: number
}
export type ErrorDetail = {
loc?: Array<unknown>
msg: string
type: string
}
export type FileResponse = {
conversation_id?: string | null
created_at?: number | null
@ -415,12 +401,6 @@ export type GetHealthData = {
url: '/_health'
}
export type GetHealthErrors = {
default: ErrorBody
}
export type GetHealthError = GetHealthErrors[keyof GetHealthErrors]
export type GetHealthResponses = {
200: HealthResponse
}
@ -434,12 +414,6 @@ export type GetVersionData = {
url: '/_version'
}
export type GetVersionErrors = {
default: ErrorBody
}
export type GetVersionError = GetVersionErrors[keyof GetVersionErrors]
export type GetVersionResponses = {
200: ServerVersionResponse
}
@ -453,12 +427,6 @@ export type GetAccountData = {
url: '/account'
}
export type GetAccountErrors = {
default: ErrorBody
}
export type GetAccountError = GetAccountErrors[keyof GetAccountErrors]
export type GetAccountResponses = {
200: AccountResponse
}
@ -475,13 +443,6 @@ export type GetAccountSessionsData = {
url: '/account/sessions'
}
export type GetAccountSessionsErrors = {
422: ErrorBody
default: ErrorBody
}
export type GetAccountSessionsError = GetAccountSessionsErrors[keyof GetAccountSessionsErrors]
export type GetAccountSessionsResponses = {
200: SessionListResponse
}
@ -496,13 +457,6 @@ export type DeleteAccountSessionsSelfData = {
url: '/account/sessions/self'
}
export type DeleteAccountSessionsSelfErrors = {
default: ErrorBody
}
export type DeleteAccountSessionsSelfError
= DeleteAccountSessionsSelfErrors[keyof DeleteAccountSessionsSelfErrors]
export type DeleteAccountSessionsSelfResponses = {
200: RevokeResponse
}
@ -519,13 +473,6 @@ export type DeleteAccountSessionsBySessionIdData = {
url: '/account/sessions/{session_id}'
}
export type DeleteAccountSessionsBySessionIdErrors = {
default: ErrorBody
}
export type DeleteAccountSessionsBySessionIdError
= DeleteAccountSessionsBySessionIdErrors[keyof DeleteAccountSessionsBySessionIdErrors]
export type DeleteAccountSessionsBySessionIdResponses = {
200: RevokeResponse
}
@ -547,13 +494,6 @@ export type GetAppsData = {
url: '/apps'
}
export type GetAppsErrors = {
422: ErrorBody
default: ErrorBody
}
export type GetAppsError = GetAppsErrors[keyof GetAppsErrors]
export type GetAppsResponses = {
200: AppListResponse
}
@ -569,13 +509,6 @@ export type GetAppsByAppIdCheckDependenciesData = {
url: '/apps/{app_id}/check-dependencies'
}
export type GetAppsByAppIdCheckDependenciesErrors = {
default: ErrorBody
}
export type GetAppsByAppIdCheckDependenciesError
= GetAppsByAppIdCheckDependenciesErrors[keyof GetAppsByAppIdCheckDependenciesErrors]
export type GetAppsByAppIdCheckDependenciesResponses = {
200: CheckDependenciesResult
}
@ -594,14 +527,6 @@ export type GetAppsByAppIdDescribeData = {
url: '/apps/{app_id}/describe'
}
export type GetAppsByAppIdDescribeErrors = {
422: ErrorBody
default: ErrorBody
}
export type GetAppsByAppIdDescribeError
= GetAppsByAppIdDescribeErrors[keyof GetAppsByAppIdDescribeErrors]
export type GetAppsByAppIdDescribeResponses = {
200: AppDescribeResponse
}
@ -621,13 +546,6 @@ export type GetAppsByAppIdExportData = {
url: '/apps/{app_id}/export'
}
export type GetAppsByAppIdExportErrors = {
422: ErrorBody
default: ErrorBody
}
export type GetAppsByAppIdExportError = GetAppsByAppIdExportErrors[keyof GetAppsByAppIdExportErrors]
export type GetAppsByAppIdExportResponses = {
200: AppDslExportResponse
}
@ -657,7 +575,6 @@ export type PostAppsByAppIdFilesUploadErrors = {
415: {
[key: string]: unknown
}
default: ErrorBody
}
export type PostAppsByAppIdFilesUploadError
@ -699,14 +616,6 @@ export type PostAppsByAppIdFormHumanInputByFormTokenData = {
url: '/apps/{app_id}/form/human_input/{form_token}'
}
export type PostAppsByAppIdFormHumanInputByFormTokenErrors = {
422: ErrorBody
default: ErrorBody
}
export type PostAppsByAppIdFormHumanInputByFormTokenError
= PostAppsByAppIdFormHumanInputByFormTokenErrors[keyof PostAppsByAppIdFormHumanInputByFormTokenErrors]
export type PostAppsByAppIdFormHumanInputByFormTokenResponses = {
200: FormSubmitResponse
}
@ -723,12 +632,6 @@ export type PostAppsByAppIdRunData = {
url: '/apps/{app_id}/run'
}
export type PostAppsByAppIdRunErrors = {
422: ErrorBody
}
export type PostAppsByAppIdRunError = PostAppsByAppIdRunErrors[keyof PostAppsByAppIdRunErrors]
export type PostAppsByAppIdRunResponses = {
200: {
[key: string]: unknown
@ -767,13 +670,6 @@ export type PostAppsByAppIdTasksByTaskIdStopData = {
url: '/apps/{app_id}/tasks/{task_id}/stop'
}
export type PostAppsByAppIdTasksByTaskIdStopErrors = {
default: ErrorBody
}
export type PostAppsByAppIdTasksByTaskIdStopError
= PostAppsByAppIdTasksByTaskIdStopErrors[keyof PostAppsByAppIdTasksByTaskIdStopErrors]
export type PostAppsByAppIdTasksByTaskIdStopResponses = {
200: TaskStopResponse
}
@ -867,14 +763,6 @@ export type GetPermittedExternalAppsData = {
url: '/permitted-external-apps'
}
export type GetPermittedExternalAppsErrors = {
422: ErrorBody
default: ErrorBody
}
export type GetPermittedExternalAppsError
= GetPermittedExternalAppsErrors[keyof GetPermittedExternalAppsErrors]
export type GetPermittedExternalAppsResponses = {
200: PermittedExternalAppsListResponse
}
@ -889,12 +777,6 @@ export type GetWorkspacesData = {
url: '/workspaces'
}
export type GetWorkspacesErrors = {
default: ErrorBody
}
export type GetWorkspacesError = GetWorkspacesErrors[keyof GetWorkspacesErrors]
export type GetWorkspacesResponses = {
200: WorkspaceListResponse
}
@ -910,13 +792,6 @@ export type GetWorkspacesByWorkspaceIdData = {
url: '/workspaces/{workspace_id}'
}
export type GetWorkspacesByWorkspaceIdErrors = {
default: ErrorBody
}
export type GetWorkspacesByWorkspaceIdError
= GetWorkspacesByWorkspaceIdErrors[keyof GetWorkspacesByWorkspaceIdErrors]
export type GetWorkspacesByWorkspaceIdResponses = {
200: WorkspaceDetailResponse
}
@ -935,8 +810,6 @@ export type PostWorkspacesByWorkspaceIdAppsImportsData = {
export type PostWorkspacesByWorkspaceIdAppsImportsErrors = {
400: Import
422: ErrorBody
default: ErrorBody
}
export type PostWorkspacesByWorkspaceIdAppsImportsError
@ -962,7 +835,6 @@ export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmData = {
export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmErrors = {
400: Import
default: ErrorBody
}
export type PostWorkspacesByWorkspaceIdAppsImportsByImportIdConfirmError
@ -987,14 +859,6 @@ export type GetWorkspacesByWorkspaceIdMembersData = {
url: '/workspaces/{workspace_id}/members'
}
export type GetWorkspacesByWorkspaceIdMembersErrors = {
422: ErrorBody
default: ErrorBody
}
export type GetWorkspacesByWorkspaceIdMembersError
= GetWorkspacesByWorkspaceIdMembersErrors[keyof GetWorkspacesByWorkspaceIdMembersErrors]
export type GetWorkspacesByWorkspaceIdMembersResponses = {
200: MemberListResponse
}
@ -1011,14 +875,6 @@ export type PostWorkspacesByWorkspaceIdMembersData = {
url: '/workspaces/{workspace_id}/members'
}
export type PostWorkspacesByWorkspaceIdMembersErrors = {
422: ErrorBody
default: ErrorBody
}
export type PostWorkspacesByWorkspaceIdMembersError
= PostWorkspacesByWorkspaceIdMembersErrors[keyof PostWorkspacesByWorkspaceIdMembersErrors]
export type PostWorkspacesByWorkspaceIdMembersResponses = {
201: MemberInviteResponse
}
@ -1036,13 +892,6 @@ export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdData = {
url: '/workspaces/{workspace_id}/members/{member_id}'
}
export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdErrors = {
default: ErrorBody
}
export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdError
= DeleteWorkspacesByWorkspaceIdMembersByMemberIdErrors[keyof DeleteWorkspacesByWorkspaceIdMembersByMemberIdErrors]
export type DeleteWorkspacesByWorkspaceIdMembersByMemberIdResponses = {
200: MemberActionResponse
}
@ -1060,14 +909,6 @@ export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleData = {
url: '/workspaces/{workspace_id}/members/{member_id}/role'
}
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleErrors = {
422: ErrorBody
default: ErrorBody
}
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleError
= PutWorkspacesByWorkspaceIdMembersByMemberIdRoleErrors[keyof PutWorkspacesByWorkspaceIdMembersByMemberIdRoleErrors]
export type PutWorkspacesByWorkspaceIdMembersByMemberIdRoleResponses = {
200: MemberActionResponse
}
@ -1084,13 +925,6 @@ export type PostWorkspacesByWorkspaceIdSwitchData = {
url: '/workspaces/{workspace_id}/switch'
}
export type PostWorkspacesByWorkspaceIdSwitchErrors = {
default: ErrorBody
}
export type PostWorkspacesByWorkspaceIdSwitchError
= PostWorkspacesByWorkspaceIdSwitchErrors[keyof PostWorkspacesByWorkspaceIdSwitchErrors]
export type PostWorkspacesByWorkspaceIdSwitchResponses = {
200: WorkspaceDetailResponse
}

View File

@ -156,30 +156,6 @@ export const zDevicePollRequest = z.object({
device_code: z.string(),
})
/**
* ErrorDetail
*/
export const zErrorDetail = z.object({
loc: z.array(z.unknown()).optional().default([]),
msg: z.string(),
type: z.string(),
})
/**
* ErrorBody
*
* Canonical non-2xx body. ``code`` is typed ``str`` (not the enum) so the
* generated client schema stays an open enum — old CLIs keep parsing when a
* future server adds a code. Formatter tests pin emitted values to the enum.
*/
export const zErrorBody = z.object({
code: z.string(),
details: z.array(zErrorDetail).nullish(),
hint: z.string().nullish(),
message: z.string(),
status: z.int(),
})
/**
* FileResponse
*/

View File

@ -39,15 +39,10 @@ describe('Tabs wrappers', () => {
await expect.element(screen.getByRole('tablist')).toHaveClass(
'flex',
'gap-4',
)
await expect.element(screen.getByRole('tab', { name: 'First' })).toHaveClass(
'touch-manipulation',
'focus-visible:outline-hidden',
'border-b-2',
'border-transparent',
'data-active:border-components-tab-active',
'data-active:text-text-primary',
)
})

View File

@ -26,11 +26,17 @@ type Story = StoryObj<typeof meta>
export const Basic: Story = {
render: () => (
<Tabs defaultValue="overview" className="w-96">
<TabsList>
<TabsTab value="overview">
<TabsList className="gap-4 border-b border-divider-subtle">
<TabsTab
value="overview"
className="border-b border-transparent px-0 py-2 system-sm-medium text-text-tertiary data-active:border-text-accent data-active:text-text-primary"
>
Overview
</TabsTab>
<TabsTab value="activity">
<TabsTab
value="activity"
className="border-b border-transparent px-0 py-2 system-sm-medium text-text-tertiary data-active:border-text-accent data-active:text-text-primary"
>
Activity
</TabsTab>
</TabsList>

View File

@ -18,7 +18,7 @@ export function TabsList({
}: TabsListProps) {
return (
<BaseTabs.List
className={cn('flex gap-4', className)}
className={cn('flex', className)}
{...props}
/>
)
@ -34,7 +34,7 @@ export function TabsTab({
}: TabsTabProps) {
return (
<BaseTabs.Tab
className={cn('touch-manipulation focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid relative flex cursor-pointer items-center border-b-2 border-transparent pt-2.5 pb-2 system-md-semibold text-text-tertiary data-active:border-components-tab-active data-active:text-text-primary data-disabled:cursor-not-allowed data-disabled:text-text-tertiary data-disabled:opacity-30 data-active:data-disabled:text-text-primary', className)}
className={cn('touch-manipulation focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-state-accent-solid data-disabled:cursor-not-allowed data-disabled:text-text-disabled', className)}
{...props}
/>
)

View File

@ -197,13 +197,13 @@ describe('TextGeneration', () => {
expect(screen.getByTestId('run-once-input-name')).toHaveTextContent('Gamma')
})
fireEvent.click(screen.getByRole('tab', { name: 'share.generation.tabs.batch' }))
fireEvent.click(screen.getByTestId('tab-header-item-batch'))
expect(screen.getByRole('button', { name: 'run-batch' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: /^share\.generation\.tabs\.saved/ }))
fireEvent.click(screen.getByTestId('tab-header-item-saved'))
expect(screen.getByTestId('saved-items-mock')).toHaveTextContent('2')
fireEvent.click(screen.getByRole('tab', { name: 'share.generation.tabs.create' }))
fireEvent.click(screen.getByTestId('tab-header-item-create'))
expect(screen.getByTestId('run-once-mock')).toBeInTheDocument()
})
@ -220,7 +220,7 @@ describe('TextGeneration', () => {
})
expect(screen.getByTestId('result-single')).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: 'share.generation.tabs.batch' }))
fireEvent.click(screen.getByTestId('tab-header-item-batch'))
fireEvent.click(screen.getByRole('button', { name: 'run-batch' }))
await waitFor(() => {
expect(screen.getByText('idle')).toBeInTheDocument()

View File

@ -46,8 +46,8 @@ const NavLink = ({
const isActive = active ?? (href ? href.toLowerCase().split('/')?.pop() === formattedSegment : false)
const NavIcon = isActive ? iconMap.selected : iconMap.normal
const linkClassName = cn(isActive
? 'border-t-[0.75px] border-r-[0.25px] border-b-[0.25px] border-l-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active system-sm-semibold text-text-accent-light-mode-only'
: 'system-sm-medium text-components-menu-item-text hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pr-1 pl-3')
? 'border-b-[0.25px] border-l-[0.75px] border-r-[0.25px] border-t-[0.75px] border-effects-highlight-lightmode-off bg-components-menu-item-bg-active text-text-accent-light-mode-only system-sm-semibold'
: 'text-components-menu-item-text system-sm-medium hover:bg-components-menu-item-bg-hover hover:text-components-menu-item-text-hover', 'flex h-8 items-center rounded-lg pl-3 pr-1')
const renderIcon = () => (
<div className={cn(mode !== 'expand' && '-ml-1')}>

View File

@ -6,7 +6,6 @@ import { cn } from '@langgenius/dify-ui/cn'
import { toast } from '@langgenius/dify-ui/toast'
import { noop } from 'es-toolkit/function'
import { decode } from 'html-entities'
import Recorder from 'js-audio-recorder'
import { useCallback, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import Textarea from 'react-textarea-autosize'
@ -14,12 +13,18 @@ import FeatureBar from '@/app/components/base/features/new-feature-panel/feature
import { FileListInChatInput } from '@/app/components/base/file-uploader'
import { useFile } from '@/app/components/base/file-uploader/hooks'
import { FileContextProvider, useFileStore } from '@/app/components/base/file-uploader/store'
import VoiceInput from '@/app/components/base/voice-input'
import dynamic from '@/next/dynamic'
import { TransferMethod } from '@/types/app'
import { useCheckInputsForms } from '../check-input-forms-hooks'
import { useTextAreaHeight } from './hooks'
import Operation from './operation'
const VoiceInput = dynamic(() => import('@/app/components/base/voice-input'), { ssr: false })
type RecorderConstructorWithPermission = typeof import('js-audio-recorder').default & {
getPermission: () => Promise<void>
}
type ChatInputAreaProps = {
readonly?: boolean
botName?: string
@ -128,12 +133,16 @@ const ChatInputArea = ({ readonly, botName, showFeatureBar, showFileUpload, feat
}
}
}
const handleShowVoiceInput = useCallback(() => {
(Recorder as any).getPermission().then(() => {
const handleShowVoiceInput = useCallback(async () => {
const { default: Recorder } = await import('js-audio-recorder')
try {
await (Recorder as RecorderConstructorWithPermission).getPermission()
setShowVoiceInput(true)
}, () => {
}
catch {
toast.error(t('voiceInput.notAllow', { ns: 'common' }))
})
}
}, [t])
const operation = (<Operation ref={holdSpaceRef} readonly={readonly} fileConfig={visionConfig} speechToTextConfig={speechToTextConfig} onShowVoiceInput={handleShowVoiceInput} onSend={handleSend} theme={theme} />)
return (

View File

@ -0,0 +1,114 @@
import { render, screen, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, expect, it, vi } from 'vitest'
import TabHeader from '../index'
describe('TabHeader Component', () => {
const mockItems = [
{ id: 'tab1', name: 'General' },
{ id: 'tab2', name: 'Settings' },
{ id: 'tab3', name: 'Profile', isRight: true },
{ id: 'tab4', name: 'Disabled Tab', disabled: true },
]
it('should render all items with correct names', () => {
render(<TabHeader items={mockItems} value="tab1" onChange={() => { }} />)
expect(screen.getByText('General')).toBeInTheDocument()
expect(screen.getByText('Settings')).toBeInTheDocument()
expect(screen.getByText('Profile')).toBeInTheDocument()
expect(screen.getByText('Disabled Tab')).toBeInTheDocument()
})
it('should separate items into left and right containers correctly', () => {
render(<TabHeader items={mockItems} value="tab1" onChange={() => { }} />)
const leftContainer = screen.getByTestId('tab-header-left')
const rightContainer = screen.getByTestId('tab-header-right')
// Verify children count
expect(leftContainer.children.length).toBe(3)
expect(rightContainer.children.length).toBe(1)
// Verify specific item placement using within and toContainElement
const profileTab = screen.getByTestId('tab-header-item-tab3')
expect(rightContainer).toContainElement(profileTab)
const disabledTab = screen.getByTestId('tab-header-item-tab4')
expect(leftContainer).toContainElement(disabledTab)
})
it('should apply active styles to the selected tab', () => {
const activeClass = 'custom-active-style'
render(
<TabHeader
items={mockItems}
value="tab2"
activeItemClassName={activeClass}
onChange={() => { }}
/>,
)
const activeTab = screen.getByTestId('tab-header-item-tab2')
expect(activeTab).toHaveClass('border-components-tab-active')
expect(activeTab).toHaveClass(activeClass)
const inactiveTab = screen.getByTestId('tab-header-item-tab1')
expect(inactiveTab).toHaveClass('text-text-tertiary')
})
it('should call onChange when a non-disabled tab is clicked', async () => {
const user = userEvent.setup()
const handleChange = vi.fn()
render(<TabHeader items={mockItems} value="tab1" onChange={handleChange} />)
await user.click(screen.getByText('Settings'))
expect(handleChange).toHaveBeenCalledWith('tab2')
})
it('should not call onChange when a disabled tab is clicked', async () => {
const user = userEvent.setup()
const handleChange = vi.fn()
render(<TabHeader items={mockItems} value="tab1" onChange={handleChange} />)
const disabledTab = screen.getByTestId('tab-header-item-tab4')
expect(disabledTab).toHaveClass('cursor-not-allowed')
await user.click(disabledTab)
expect(handleChange).not.toHaveBeenCalled()
})
it('should render icon and extra content when provided', () => {
const itemsWithExtras = [
{
id: 'extra',
name: 'Extra',
icon: <span data-testid="tab-icon">🚀</span>,
extra: <span data-testid="tab-extra">New</span>,
},
]
render(<TabHeader items={itemsWithExtras} value="extra" onChange={() => { }} />)
expect(screen.getByTestId('tab-icon')).toBeInTheDocument()
expect(screen.getByTestId('tab-extra')).toBeInTheDocument()
})
it('should apply custom class names for items and wrappers', () => {
render(
<TabHeader
items={mockItems}
value="tab1"
itemClassName="my-text-class"
itemWrapClassName="my-wrap-class"
onChange={() => { }}
/>,
)
const tabWrap = screen.getByTestId('tab-header-item-tab1')
// We target the inner div for the name class check
const tabText = within(tabWrap).getByText('General')
expect(tabWrap).toHaveClass('my-wrap-class')
expect(tabText).toHaveClass('my-text-class')
})
})

View File

@ -0,0 +1,66 @@
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
import type { ITabHeaderProps } from '.'
import { useState } from 'react'
import TabHeader from '.'
const items: ITabHeaderProps['items'] = [
{ id: 'overview', name: 'Overview' },
{ id: 'playground', name: 'Playground' },
{ id: 'changelog', name: 'Changelog', extra: <span className="ml-1 rounded-full bg-primary-50 px-2 py-0.5 text-xs text-primary-600">New</span> },
{ id: 'docs', name: 'Docs', isRight: true },
{ id: 'settings', name: 'Settings', isRight: true, disabled: true },
]
const TabHeaderDemo = ({
initialTab = 'overview',
}: {
initialTab?: string
}) => {
const [activeTab, setActiveTab] = useState(initialTab)
return (
<div className="flex w-full max-w-3xl flex-col gap-6 rounded-2xl border border-divider-subtle bg-components-panel-bg p-6">
<div className="flex items-center justify-between text-xs tracking-[0.18em] text-text-tertiary uppercase">
<span>Tabs</span>
<code className="rounded-md bg-background-default px-2 py-1 text-[11px] text-text-tertiary">
active="
{activeTab}
"
</code>
</div>
<TabHeader
items={items}
value={activeTab}
onChange={setActiveTab}
/>
</div>
)
}
const meta = {
title: 'Base/Navigation/TabHeader',
component: TabHeaderDemo,
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Two-sided header tabs with optional right-aligned actions. Disabled items illustrate read-only states.',
},
},
},
argTypes: {
initialTab: {
control: 'radio',
options: items.map(item => item.id),
},
},
args: {
initialTab: 'overview',
},
tags: ['autodocs'],
} satisfies Meta<typeof TabHeaderDemo>
export default meta
type Story = StoryObj<typeof meta>
export const Playground: Story = {}

View File

@ -0,0 +1,60 @@
'use client'
import type { FC } from 'react'
import { cn } from '@langgenius/dify-ui/cn'
import * as React from 'react'
type Item = {
id: string
name: string
isRight?: boolean
icon?: React.ReactNode
extra?: React.ReactNode
disabled?: boolean
}
export type ITabHeaderProps = Readonly<{
items: Item[]
value: string
itemClassName?: string
itemWrapClassName?: string
activeItemClassName?: string
onChange: (value: string) => void
}>
const TabHeader: FC<ITabHeaderProps> = ({
items,
value,
itemClassName,
itemWrapClassName,
activeItemClassName,
onChange,
}) => {
const renderItem = ({ id, name, icon, extra, disabled }: Item) => (
<div
key={id}
data-testid={`tab-header-item-${id}`}
className={cn(
'relative flex cursor-pointer items-center border-b-2 border-transparent pt-2.5 pb-2 system-md-semibold',
id === value ? cn('border-components-tab-active text-text-primary', activeItemClassName) : 'text-text-tertiary',
disabled && 'cursor-not-allowed opacity-30',
itemWrapClassName,
)}
onClick={() => !disabled && onChange(id)}
>
{icon || ''}
<div className={cn('ml-2', itemClassName)}>{name}</div>
{extra || ''}
</div>
)
return (
<div data-testid="tab-header" className="flex justify-between">
<div data-testid="tab-header-left" className="flex space-x-4">
{items.filter(item => !item.isRight).map(renderItem)}
</div>
<div data-testid="tab-header-right" className="flex space-x-4">
{items.filter(item => item.isRight).map(renderItem)}
</div>
</div>
)
}
export default React.memo(TabHeader)

View File

@ -98,6 +98,9 @@ describe('VoiceInput', () => {
it('should start recording on mount and show speaking state', async () => {
render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
await waitFor(() => {
expect(mockState.recorderInstances).toHaveLength(1)
})
// eslint-disable-next-line ts/no-explicit-any
const recorder = mockState.recorderInstances[0] as any
expect(recorder.start).toHaveBeenCalled()
@ -390,8 +393,11 @@ describe('VoiceInput', () => {
expect(await screen.findByText('common.voiceInput.speaking'))!.toBeInTheDocument()
})
it('should cleanup on unmount', () => {
it('should cleanup on unmount', async () => {
const { unmount } = render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
await waitFor(() => {
expect(mockState.recorderInstances).toHaveLength(1)
})
// eslint-disable-next-line ts/no-explicit-any
const recorder = mockState.recorderInstances[0] as any
@ -400,6 +406,31 @@ describe('VoiceInput', () => {
expect(recorder.stop).toHaveBeenCalled()
})
it('should stop without cancelling after unmount while recording start is pending', async () => {
let resolveStart!: () => void
mockState.startOverride = () => new Promise<void>((resolve) => {
resolveStart = resolve
})
const { unmount } = render(<VoiceInput onConverted={onConverted} onCancel={onCancel} />)
await waitFor(() => {
expect(mockState.recorderInstances).toHaveLength(1)
})
// eslint-disable-next-line ts/no-explicit-any
const recorder = mockState.recorderInstances[0] as any
unmount()
expect(recorder.stop).toHaveBeenCalledTimes(1)
await act(async () => {
resolveStart()
await Promise.resolve()
})
expect(recorder.stop).toHaveBeenCalledTimes(2)
expect(onCancel).not.toHaveBeenCalled()
})
it('should handle all data in recordAnalyseData for canvas drawing', async () => {
const allDataValues = []
for (let i = 0; i < 256; i++) {

View File

@ -1,12 +1,13 @@
'use client'
import type Recorder from 'js-audio-recorder'
import { cn } from '@langgenius/dify-ui/cn'
import { useRafInterval } from 'ahooks'
import Recorder from 'js-audio-recorder'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useParams, usePathname } from '@/next/navigation'
import { AppSourceType, audioToText } from '@/service/share'
import s from './index.module.css'
import { convertToMp3 } from './utils'
type VoiceInputTypes = {
onConverted: (text: string) => void
@ -20,15 +21,11 @@ const VoiceInput = ({
wordTimestamps,
}: VoiceInputTypes) => {
const { t } = useTranslation()
const recorder = useRef(new Recorder({
sampleBits: 16,
sampleRate: 16000,
numChannels: 1,
compiling: false,
}))
const recorderRef = useRef<Recorder | null>(null)
const mountedRef = useRef(false)
const canvasRef = useRef<HTMLCanvasElement | null>(null)
const ctxRef = useRef<CanvasRenderingContext2D | null>(null)
const drawRecordId = useRef<number | null>(null)
const drawRecordIdRef = useRef<number | null>(null)
const [originDuration, setOriginDuration] = useState(0)
const [startRecord, setStartRecord] = useState(false)
const [startConvert, setStartConvert] = useState(false)
@ -38,11 +35,29 @@ const VoiceInput = ({
setOriginDuration(originDuration + 1)
}, 1000)
const getRecorder = useCallback(async () => {
if (!recorderRef.current) {
const { default: Recorder } = await import('js-audio-recorder')
recorderRef.current = new Recorder({
sampleBits: 16,
sampleRate: 16000,
numChannels: 1,
compiling: false,
})
}
return recorderRef.current
}, [])
const drawRecord = useCallback(() => {
drawRecordId.current = requestAnimationFrame(drawRecord)
drawRecordIdRef.current = requestAnimationFrame(drawRecord)
const canvas = canvasRef.current!
const ctx = ctxRef.current!
const dataUnit8Array = recorder.current.getRecordAnalyseData()
const currentRecorder = recorderRef.current
if (!currentRecorder)
return
const dataUnit8Array = currentRecorder.getRecordAnalyseData()
const dataArray = [].slice.call(dataUnit8Array)
const lineLength = Number.parseInt(`${canvas.width / 3}`)
const gap = Number.parseInt(`${1024 / lineLength}`)
@ -72,17 +87,22 @@ const VoiceInput = ({
ctx.closePath()
}, [])
const handleStopRecorder = useCallback(async () => {
const currentRecorder = recorderRef.current
if (!currentRecorder)
return
clearInterval()
setStartRecord(false)
setStartConvert(true)
recorder.current.stop()
if (drawRecordId.current)
cancelAnimationFrame(drawRecordId.current)
drawRecordId.current = null
currentRecorder.stop()
if (drawRecordIdRef.current)
cancelAnimationFrame(drawRecordIdRef.current)
drawRecordIdRef.current = null
const canvas = canvasRef.current!
const ctx = ctxRef.current!
ctx.clearRect(0, 0, canvas.width, canvas.height)
const mp3Blob = convertToMp3(recorder.current)
const { convertToMp3 } = await import('./utils')
const mp3Blob = convertToMp3(currentRecorder)
const mp3File = new File([mp3Blob], 'temp.mp3', { type: 'audio/mp3' })
const formData = new FormData()
formData.append('file', mp3File)
@ -114,7 +134,20 @@ const VoiceInput = ({
}, [clearInterval, onCancel, onConverted, params.appId, params.token, pathname, wordTimestamps])
const handleStartRecord = useCallback(async () => {
try {
await recorder.current.start()
const currentRecorder = await getRecorder()
if (!mountedRef.current) {
currentRecorder.stop()
return
}
await currentRecorder.start()
if (!mountedRef.current) {
currentRecorder.stop()
return
}
setStartRecord(true)
setStartConvert(false)
@ -122,9 +155,10 @@ const VoiceInput = ({
drawRecord()
}
catch {
onCancel()
if (mountedRef.current)
onCancel()
}
}, [drawRecord, onCancel, setStartRecord, setStartConvert])
}, [drawRecord, getRecorder, onCancel])
const initCanvas = useCallback(() => {
const dpr = window.devicePixelRatio || 1
const canvas = document.getElementById('voice-input-record') as HTMLCanvasElement
@ -148,11 +182,14 @@ const VoiceInput = ({
handleStopRecorder()
useEffect(() => {
mountedRef.current = true
initCanvas()
handleStartRecord()
const recorderRef = recorder?.current
return () => {
recorderRef?.stop()
mountedRef.current = false
if (drawRecordIdRef.current)
cancelAnimationFrame(drawRecordIdRef.current)
recorderRef.current?.stop()
}
}, [handleStartRecord, initCanvas])

View File

@ -3,7 +3,7 @@ import { cleanup, fireEvent, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { renderWithSystemFeatures as render } from '@/__tests__/utils/mock-system-features'
import TryApp from '../index'
import { TypeEnum } from '../types'
import { TypeEnum } from '../tab'
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal() as object
@ -213,7 +213,8 @@ describe('TryApp (main index.tsx)', () => {
)
await waitFor(() => {
expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument()
const buttons = document.body.querySelectorAll('button')
expect(buttons.length).toBeGreaterThan(0)
})
})
})
@ -280,10 +281,15 @@ describe('TryApp (main index.tsx)', () => {
)
await waitFor(() => {
expect(screen.getByRole('button', { name: 'common.operation.close' })).toBeInTheDocument()
})
const buttons = document.body.querySelectorAll('button')
const closeButton = Array.from(buttons).find(btn =>
btn.querySelector('svg') || btn.className.includes('rounded-[10px]'),
)
expect(closeButton).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
if (closeButton)
fireEvent.click(closeButton)
})
expect(mockOnClose).toHaveBeenCalled()
})

View File

@ -0,0 +1,54 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import Tab, { TypeEnum } from '../tab'
vi.mock('@/config', async (importOriginal) => {
const actual = await importOriginal() as object
return {
...actual,
IS_CLOUD_EDITION: true,
}
})
describe('Tab', () => {
afterEach(() => {
cleanup()
})
it('renders tab with TRY value selected', () => {
const mockOnChange = vi.fn()
render(<Tab value={TypeEnum.TRY} onChange={mockOnChange} />)
expect(screen.getByText('explore.tryApp.tabHeader.try')).toBeInTheDocument()
expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument()
})
it('renders tab with DETAIL value selected', () => {
const mockOnChange = vi.fn()
render(<Tab value={TypeEnum.DETAIL} onChange={mockOnChange} />)
expect(screen.getByText('explore.tryApp.tabHeader.try')).toBeInTheDocument()
expect(screen.getByText('explore.tryApp.tabHeader.detail')).toBeInTheDocument()
})
it('calls onChange when clicking a tab', () => {
const mockOnChange = vi.fn()
render(<Tab value={TypeEnum.TRY} onChange={mockOnChange} />)
fireEvent.click(screen.getByText('explore.tryApp.tabHeader.detail'))
expect(mockOnChange).toHaveBeenCalledWith(TypeEnum.DETAIL)
})
it('calls onChange when clicking Try tab', () => {
const mockOnChange = vi.fn()
render(<Tab value={TypeEnum.DETAIL} onChange={mockOnChange} />)
fireEvent.click(screen.getByText('explore.tryApp.tabHeader.try'))
expect(mockOnChange).toHaveBeenCalledWith(TypeEnum.TRY)
})
it('exports TypeEnum correctly', () => {
expect(TypeEnum.TRY).toBe('try')
expect(TypeEnum.DETAIL).toBe('detail')
})
})

View File

@ -4,11 +4,9 @@ import type { FC } from 'react'
import type { App as AppType } from '@/models/explore'
import { Button } from '@langgenius/dify-ui/button'
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
import { useSuspenseQuery } from '@tanstack/react-query'
import * as React from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import AppUnavailable from '@/app/components/base/app-unavailable'
import Loading from '@/app/components/base/loading'
import { IS_CLOUD_EDITION } from '@/config'
@ -17,7 +15,7 @@ import { useGetTryAppInfo } from '@/service/use-try-app'
import App from './app'
import AppInfo from './app-info'
import Preview from './preview'
import { TypeEnum } from './types'
import Tab, { TypeEnum } from './tab'
type Props = {
appId: string
@ -34,7 +32,6 @@ const TryApp: FC<Props> = ({
onClose,
onCreate,
}) => {
const { t } = useTranslation()
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
const isTrialApp = !!(app && app.can_trial && systemFeatures.enable_trial_app)
const canUseTryTab = IS_CLOUD_EDITION && (app ? isTrialApp : true)
@ -65,49 +62,25 @@ const TryApp: FC<Props> = ({
<AppUnavailable className="size-auto" isUnknownReason />
</div>
) : (
<Tabs
value={activeType}
onValueChange={selectedValue => setType(selectedValue)}
className="flex h-full flex-col"
>
<div className="flex h-full flex-col">
<div className="flex shrink-0 justify-between pl-4">
<TabsList>
{IS_CLOUD_EDITION && (
<TabsTab
value={TypeEnum.TRY}
disabled={app ? !isTrialApp : false}
className="pt-2 data-active:border-util-colors-blue-brand-blue-brand-500"
>
<span className="system-md-semibold-uppercase">{t('tryApp.tabHeader.try', { ns: 'explore' })}</span>
</TabsTab>
)}
<TabsTab
value={TypeEnum.DETAIL}
className="pt-2 data-active:border-util-colors-blue-brand-blue-brand-500"
>
<span className="system-md-semibold-uppercase">{t('tryApp.tabHeader.detail', { ns: 'explore' })}</span>
</TabsTab>
</TabsList>
<Tab
value={activeType}
onChange={setType}
disableTry={app ? !isTrialApp : false}
/>
<Button
size="large"
variant="tertiary"
aria-label={t('common.operation.close')}
className="flex size-7 items-center justify-center rounded-[10px] p-0 text-components-button-tertiary-text"
onClick={onClose}
>
<span aria-hidden className="i-ri-close-line size-5" />
<span className="i-ri-close-line size-5" />
</Button>
</div>
{/* Main content */}
<div className="mt-2 flex h-0 grow justify-between space-x-2">
{IS_CLOUD_EDITION && (
<TabsPanel value={TypeEnum.TRY} className="min-w-0 flex-1">
<App appId={appId} appDetail={appDetail} />
</TabsPanel>
)}
<TabsPanel value={TypeEnum.DETAIL} className="min-w-0 flex-1">
<Preview appId={appId} appDetail={appDetail} />
</TabsPanel>
{activeType === TypeEnum.TRY ? <App appId={appId} appDetail={appDetail} /> : <Preview appId={appId} appDetail={appDetail} />}
<AppInfo
className="w-[360px] shrink-0"
appDetail={appDetail}
@ -116,7 +89,7 @@ const TryApp: FC<Props> = ({
onCreate={onCreate}
/>
</div>
</Tabs>
</div>
)}
</DialogContent>
</Dialog>

View File

@ -0,0 +1,43 @@
'use client'
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import { IS_CLOUD_EDITION } from '@/config'
import TabHeader from '../../base/tab-header'
export enum TypeEnum {
TRY = 'try',
DETAIL = 'detail',
}
type Props = {
value: TypeEnum
onChange: (value: TypeEnum) => void
disableTry?: boolean
}
const Tab: FC<Props> = ({
value,
onChange,
disableTry,
}) => {
const { t } = useTranslation()
const tabs = React.useMemo(() => {
return [
IS_CLOUD_EDITION ? { id: TypeEnum.TRY, name: t('tryApp.tabHeader.try', { ns: 'explore' }), disabled: disableTry } : null,
{ id: TypeEnum.DETAIL, name: t('tryApp.tabHeader.detail', { ns: 'explore' }) },
].filter(item => item !== null) as { id: TypeEnum, name: string }[]
}, [t, disableTry])
return (
<TabHeader
items={tabs}
value={value}
onChange={onChange as (value: string) => void}
itemClassName="ml-0 system-md-semibold-uppercase"
itemWrapClassName="pt-2"
activeItemClassName="border-util-colors-blue-brand-blue-brand-500"
/>
)
}
export default React.memo(Tab)

View File

@ -1,6 +0,0 @@
export const TypeEnum = {
TRY: 'try',
DETAIL: 'detail',
} as const
export type TypeEnum = typeof TypeEnum[keyof typeof TypeEnum]

View File

@ -113,7 +113,6 @@ describe('TextGenerationSidebar', () => {
expect(screen.getByText('Text Generation')).toBeInTheDocument()
expect(screen.getByText('Share description')).toBeInTheDocument()
expect(screen.getByRole('tablist')).toHaveClass('w-full')
expect(screen.getByTestId('run-once-mock')).toBeInTheDocument()
expect(runOncePropsSpy).toHaveBeenCalledWith(expect.objectContaining({
inputs: { name: 'Alice' },
@ -135,7 +134,7 @@ describe('TextGenerationSidebar', () => {
vars: promptConfig.prompt_variables,
isAllFinished: true,
}))
expect(screen.queryByRole('tab', { name: /^share\.generation\.tabs\.saved/ })).not.toBeInTheDocument()
expect(screen.queryByTestId('tab-header-item-saved')).not.toBeInTheDocument()
})
it('should render saved items and allow switching back to create tab', () => {

View File

@ -5,7 +5,6 @@ import type { PromptConfig, SavedMessage, TextToSpeechConfig } from '@/models/de
import type { SiteInfo } from '@/models/share'
import type { VisionFile, VisionSettings } from '@/types/app'
import { cn } from '@langgenius/dify-ui/cn'
import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
import { useTranslation } from 'react-i18next'
import SavedItems from '@/app/components/app/text-generate/saved-items'
import AppIcon from '@/app/components/base/app-icon'
@ -13,6 +12,7 @@ import Badge from '@/app/components/base/badge'
import DifyLogo from '@/app/components/base/logo/dify-logo'
import { appDefaultIconBackground } from '@/config'
import { AccessMode } from '@/models/access-control'
import TabHeader from '../../base/tab-header'
import MenuDropdown from './menu-dropdown'
import RunBatch from './run-batch'
import RunOnce from './run-once'
@ -71,9 +71,7 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({
const { t } = useTranslation()
return (
<Tabs
value={currentTab}
onValueChange={onTabChange}
<div
className={cn(
'relative flex h-full shrink-0 flex-col',
isPC ? 'w-[600px] max-w-[50%]' : resultExisted ? 'h-[calc(100%-64px)]' : '',
@ -95,25 +93,29 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({
{siteInfo.description && (
<div className="system-xs-regular text-text-tertiary">{siteInfo.description}</div>
)}
<TabsList className="w-full">
<TabsTab value="create">
<span className="ml-2">{t('generation.tabs.create', { ns: 'share' })}</span>
</TabsTab>
<TabsTab value="batch">
<span className="ml-2">{t('generation.tabs.batch', { ns: 'share' })}</span>
</TabsTab>
{!isWorkflow && (
<TabsTab value="saved" className="ml-auto">
<span aria-hidden className="i-ri-bookmark-3-line size-4" />
<span className="ml-2">{t('generation.tabs.saved', { ns: 'share' })}</span>
{savedMessages.length > 0 && (
<Badge className="ml-1">
{savedMessages.length}
</Badge>
)}
</TabsTab>
)}
</TabsList>
<TabHeader
items={[
{ id: 'create', name: t('generation.tabs.create', { ns: 'share' }) },
{ id: 'batch', name: t('generation.tabs.batch', { ns: 'share' }) },
...(!isWorkflow
? [{
id: 'saved',
name: t('generation.tabs.saved', { ns: 'share' }),
isRight: true,
icon: <span aria-hidden className="i-ri-bookmark-3-line size-4" />,
extra: savedMessages.length > 0
? (
<Badge className="ml-1">
{savedMessages.length}
</Badge>
)
: null,
}]
: []),
]}
value={currentTab}
onChange={onTabChange}
/>
</div>
<div
className={cn(
@ -122,7 +124,7 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({
!isPC && resultExisted && customConfig?.remove_webapp_brand && 'rounded-b-2xl border-b-[0.5px] border-divider-regular',
)}
>
<TabsPanel value="create" keepMounted>
<div className={cn(currentTab === 'create' ? 'block' : 'hidden')}>
<RunOnce
siteInfo={siteInfo}
inputs={inputs}
@ -134,24 +136,22 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({
onVisionFilesChange={onVisionFilesChange}
runControl={runControl}
/>
</TabsPanel>
<TabsPanel value="batch" keepMounted>
</div>
<div className={cn(currentTab === 'batch' ? 'block' : 'hidden')}>
<RunBatch
vars={promptConfig.prompt_variables}
onSend={onBatchSend}
isAllFinished={allTasksRun}
/>
</TabsPanel>
{!isWorkflow && (
<TabsPanel value="saved">
<SavedItems
className={cn(isPC ? 'mt-6' : 'mt-4')}
isShowTextToSpeech={textToSpeechConfig?.enabled}
list={savedMessages}
onRemove={onRemoveSavedMessage}
onStartCreateContent={() => onTabChange('create')}
/>
</TabsPanel>
</div>
{currentTab === 'saved' && (
<SavedItems
className={cn(isPC ? 'mt-6' : 'mt-4')}
isShowTextToSpeech={textToSpeechConfig?.enabled}
list={savedMessages}
onRemove={onRemoveSavedMessage}
onStartCreateContent={() => onTabChange('create')}
/>
)}
</div>
{!customConfig?.remove_webapp_brand && (
@ -170,7 +170,7 @@ const TextGenerationSidebar: FC<TextGenerationSidebarProps> = ({
: <DifyLogo size="small" />}
</div>
)}
</Tabs>
</div>
)
}

View File

@ -274,6 +274,18 @@ vi.mock('../last-run', () => ({
),
}))
vi.mock('../tab', () => ({
__esModule: true,
TabType: { settings: 'settings', lastRun: 'lastRun' },
default: ({ value, onChange }: { value: string, onChange: (value: string) => void }) => (
<div>
<button onClick={() => onChange('settings')}>settings-tab</button>
<button onClick={() => onChange('lastRun')}>last-run-tab</button>
<span>{value}</span>
</div>
),
}))
vi.mock('../trigger-subscription', () => ({
TriggerSubscription: ({ children, onSubscriptionChange }: PropsWithChildren<{ onSubscriptionChange?: (value: { id: string }, callback?: () => void) => void }>) => (
<div>
@ -309,7 +321,7 @@ describe('workflow-panel index', () => {
})
it('should render the settings panel and wire title, description, run, and close actions', async () => {
renderWorkflowComponent(
const { container } = renderWorkflowComponent(
<BasePanel id="node-1" data={createData() as never}>
<div>panel-child</div>
</BasePanel>,
@ -339,7 +351,8 @@ describe('workflow-panel index', () => {
fireEvent.click(screen.getByRole('button', { name: 'workflow.panel.runThisStep' }))
fireEvent.click(screen.getByRole('button', { name: 'common.operation.close' }))
const clickableItems = container.querySelectorAll('.cursor-pointer')
fireEvent.click(clickableItems[clickableItems.length - 1] as HTMLElement)
expect(mockHandleSingleRun).toHaveBeenCalledTimes(1)
expect(mockHandleNodeSelect).toHaveBeenCalledWith('node-1', true)
@ -382,7 +395,6 @@ describe('workflow-panel index', () => {
)
expect(screen.getByText('last-run-panel')).toBeInTheDocument()
expect(screen.getByRole('tabpanel')).toHaveClass('flex', 'flex-1', 'flex-col')
})
it('should render the plain tab layout and allow last-run status updates', async () => {

View File

@ -2,7 +2,6 @@ import type { FC, ReactNode } from 'react'
import type { SimpleSubscription } from '@/app/components/plugins/plugin-detail-panel/subscription-list'
import type { Node } from '@/app/components/workflow/types'
import { cn } from '@langgenius/dify-ui/cn'
import { Tabs, TabsList, TabsPanel, TabsTab } from '@langgenius/dify-ui/tabs'
import {
Tooltip,
TooltipContent,
@ -91,8 +90,8 @@ import {
} from './helpers'
import LastRun from './last-run'
import useLastRun from './last-run/use-last-run'
import Tab, { TabType } from './tab'
import { TriggerSubscription } from './trigger-subscription'
import { TabType } from './types'
type BasePanelProps = {
children: ReactNode
@ -481,17 +480,6 @@ const BasePanel: FC<BasePanelProps> = ({
? t('debug.variableInspect.trigger.stop', { ns: 'workflow' })
: runThisStepLabel
const panelTabs = (
<TabsList>
<TabsTab value={TabType.settings}>
{t('debug.settingsTab', { ns: 'workflow' }).toLocaleUpperCase()}
</TabsTab>
<TabsTab value={TabType.lastRun}>
{t('debug.lastRunTab', { ns: 'workflow' }).toLocaleUpperCase()}
</TabsTab>
</TabsList>
)
return (
<div
className={cn(
@ -508,10 +496,8 @@ const BasePanel: FC<BasePanelProps> = ({
>
<div className="h-10 w-0.5 rounded-xs bg-state-base-handle hover:h-full hover:bg-state-accent-solid active:h-full active:bg-state-accent-solid"></div>
</div>
<Tabs
<div
ref={containerRef}
value={tabType}
onValueChange={selectedValue => setTabType(selectedValue)}
className={cn('flex h-full flex-col rounded-2xl border-[0.5px] border-components-panel-border bg-components-panel-bg shadow-lg transition-[width] ease-linear', showSingleRunPanel ? 'overflow-hidden' : 'overflow-y-auto')}
style={{
width: `${nodePanelWidth}px`,
@ -572,14 +558,12 @@ const BasePanel: FC<BasePanelProps> = ({
<HelpLink nodeType={data.type} />
<NodeActionsDropdown id={id} data={data} showHelpLink={false} />
<div className="mx-3 h-3.5 w-px bg-divider-regular" />
<button
type="button"
aria-label={t('common.operation.close')}
className="flex size-6 cursor-pointer items-center justify-center rounded-md hover:bg-state-base-hover focus-visible:ring-1 focus-visible:ring-components-input-border-hover focus-visible:outline-hidden"
<div
className="flex size-6 cursor-pointer items-center justify-center"
onClick={() => handleNodeSelect(id, true)}
>
<RiCloseLine aria-hidden className="size-4 text-text-tertiary" />
</button>
<RiCloseLine className="size-4 text-text-tertiary" />
</div>
</div>
</div>
<div className="p-2">
@ -600,7 +584,10 @@ const BasePanel: FC<BasePanelProps> = ({
}}
>
<div className="flex items-center justify-between pr-3 pl-4">
{panelTabs}
<Tab
value={tabType}
onChange={setTabType}
/>
<AuthorizedInNode
pluginPayload={{
provider: currToolCollection?.name || '',
@ -622,7 +609,10 @@ const BasePanel: FC<BasePanelProps> = ({
isAuthorized={currentDataSource.is_authorized}
>
<div className="flex items-center justify-between pr-3 pl-4">
{panelTabs}
<Tab
value={tabType}
onChange={setTabType}
/>
<AuthorizedInDataSourceNode
onJumpToDataSourcePage={handleJumpToDataSourcePage}
authorizationsNum={3}
@ -637,68 +627,76 @@ const BasePanel: FC<BasePanelProps> = ({
subscriptionIdSelected={data.subscription_id}
onSubscriptionChange={handleSubscriptionChange}
>
{panelTabs}
<Tab
value={tabType}
onChange={setTabType}
/>
</TriggerSubscription>
)
}
{
!needsToolAuth && !currentDataSource && !currentTriggerPlugin && (
<div className="flex items-center justify-between pr-3 pl-4">
{panelTabs}
<Tab
value={tabType}
onChange={setTabType}
/>
</div>
)
}
<Split />
</div>
<TabsPanel value={TabType.settings} className="flex flex-1 flex-col overflow-y-auto">
<div>
{cloneElement(children as any, {
id,
data,
panelProps: {
getInputVars,
toVarInputs,
runInputData,
setRunInputData,
runResult,
runInputDataRef,
},
})}
{tabType === TabType.settings && (
<div className="flex flex-1 flex-col overflow-y-auto">
<div>
{cloneElement(children as any, {
id,
data,
panelProps: {
getInputVars,
toVarInputs,
runInputData,
setRunInputData,
runResult,
runInputDataRef,
},
})}
</div>
<Split />
{
hasRetryNode(data.type) && (
<RetryOnPanel
id={id}
data={data}
/>
)
}
{
hasErrorHandleNode(data.type) && (
<ErrorHandleOnPanel
id={id}
data={data}
/>
)
}
{
!!availableNextBlocks.length && (
<div className="border-t-[0.5px] border-divider-regular p-4">
<div className="mb-1 flex items-center system-sm-semibold-uppercase text-text-secondary">
{t('panel.nextStep', { ns: 'workflow' }).toLocaleUpperCase()}
</div>
<div className="mb-2 system-xs-regular text-text-tertiary">
{t('panel.addNextStep', { ns: 'workflow' })}
</div>
<NextStep selectedNode={selectedNode} />
</div>
)
}
{readmeEntranceComponent}
</div>
<Split />
{
hasRetryNode(data.type) && (
<RetryOnPanel
id={id}
data={data}
/>
)
}
{
hasErrorHandleNode(data.type) && (
<ErrorHandleOnPanel
id={id}
data={data}
/>
)
}
{
!!availableNextBlocks.length && (
<div className="border-t-[0.5px] border-divider-regular p-4">
<div className="mb-1 flex items-center system-sm-semibold-uppercase text-text-secondary">
{t('panel.nextStep', { ns: 'workflow' }).toLocaleUpperCase()}
</div>
<div className="mb-2 system-xs-regular text-text-tertiary">
{t('panel.addNextStep', { ns: 'workflow' })}
</div>
<NextStep selectedNode={selectedNode} />
</div>
)
}
{readmeEntranceComponent}
</TabsPanel>
)}
<TabsPanel value={TabType.lastRun} className="flex flex-1 flex-col">
{tabType === TabType.lastRun && (
<LastRun
appId={appDetail?.id || ''}
nodeId={id}
@ -712,9 +710,9 @@ const BasePanel: FC<BasePanelProps> = ({
isPaused={isPaused}
{...passedLogParams}
/>
</TabsPanel>
)}
</Tabs>
</div>
</div>
)
}

View File

@ -38,7 +38,7 @@ import { BlockEnum } from '@/app/components/workflow/types'
import { isSupportCustomRunForm } from '@/app/components/workflow/utils'
import { VALUE_SELECTOR_DELIMITER as DELIMITER } from '@/config'
import { useInvalidLastRun } from '@/service/use-workflow'
import { TabType } from '../types'
import { TabType } from '../tab'
const singleRunFormParamsHooks: Record<BlockEnum, any> = {
[BlockEnum.LLM]: useLLMSingleRunFormParams,

View File

@ -0,0 +1,35 @@
'use client'
import type { FC } from 'react'
import * as React from 'react'
import { useTranslation } from 'react-i18next'
import TabHeader from '@/app/components/base/tab-header'
export enum TabType {
settings = 'settings',
lastRun = 'lastRun',
relations = 'relations',
}
type Props = {
value: TabType
onChange: (value: TabType) => void
}
const Tab: FC<Props> = ({
value,
onChange,
}) => {
const { t } = useTranslation()
return (
<TabHeader
items={[
{ id: TabType.settings, name: t('debug.settingsTab', { ns: 'workflow' }).toLocaleUpperCase() },
{ id: TabType.lastRun, name: t('debug.lastRunTab', { ns: 'workflow' }).toLocaleUpperCase() },
]}
itemClassName="ml-0"
value={value}
onChange={onChange as any}
/>
)
}
export default React.memo(Tab)

View File

@ -1,7 +0,0 @@
export const TabType = {
settings: 'settings',
lastRun: 'lastRun',
relations: 'relations',
} as const
export type TabType = typeof TabType[keyof typeof TabType]