diff --git a/api/constants/model_template.py b/api/constants/model_template.py index 8a027f10e57..bc76c222dfd 100644 --- a/api/constants/model_template.py +++ b/api/constants/model_template.py @@ -88,8 +88,9 @@ default_app_templates: Mapping[AppMode, Mapping] = { AppMode.AGENT: { "app": { "mode": AppMode.AGENT, - "enable_site": True, - "enable_api": True, + # Public access is enabled atomically by the first successful publish. + "enable_site": False, + "enable_api": False, }, }, } diff --git a/api/controllers/common/agent_app_parameters.py b/api/controllers/common/agent_app_parameters.py index c1c9fcdb23a..5bc41379444 100644 --- a/api/controllers/common/agent_app_parameters.py +++ b/api/controllers/common/agent_app_parameters.py @@ -3,6 +3,7 @@ from typing import Any from sqlalchemy import select from sqlalchemy.orm import Session +from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot from core.app.apps.agent_app.app_feature_projection import merge_agent_app_features from core.app.apps.agent_app.app_variable_projection import agent_app_variables_to_user_input_form from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError @@ -34,9 +35,7 @@ def get_published_agent_app_feature_dict_and_user_input_form( ) if agent is None: raise AgentAppGeneratorError("Agent App has no bound Agent") - # active_config_is_published means the draft has no unpublished edits; the public app - # can still read parameters from the active snapshot while a newer draft is pending. - if not agent.active_config_snapshot_id: + if not agent_has_workflow_callable_active_snapshot(session=session, agent=agent): raise AgentAppNotPublishedError("Agent has not been published") snapshot = session.scalar( diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index 2892e30cb05..8e233c81db8 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -43,6 +43,7 @@ from controllers.console.wraps import ( with_current_tenant_id, with_current_user, ) +from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot from fields.agent_fields import ( AgentConfigDraftSummaryResponse, AgentConfigSnapshotDetailResponse, @@ -139,6 +140,7 @@ class AgentApiStatusPayload(BaseModel): class AgentApiAccessResponse(BaseModel): + access_ready: bool enabled: bool service_api_base_url: str streaming_only: bool = True @@ -257,6 +259,7 @@ class AgentAppDetailWithSite(GenericAppDetailWithSite): debug_conversation_has_messages: bool = False debug_conversation_message_count: int = 0 role: str | None = None + access_ready: bool = False class AgentDebugConversationRefreshResponse(BaseModel): @@ -400,6 +403,7 @@ def _serialize_agent_app_detail( payload["debug_conversation_has_messages"] = message_count > 0 payload["debug_conversation_message_count"] = message_count payload["role"] = agent.role or "" + payload["access_ready"] = agent_has_workflow_callable_active_snapshot(session=session, agent=agent) return payload @@ -491,10 +495,20 @@ def _agent_api_key_count(session: Session, app_id: str) -> int: ) +def _agent_app_access_ready(session: Session, app_model: App) -> bool: + agent = _agent_roster_service(session).get_app_backing_agent( + tenant_id=app_model.tenant_id, + app_id=str(app_model.id), + ) + return bool(agent and agent_has_workflow_callable_active_snapshot(session=session, agent=agent)) + + def _serialize_agent_api_access(session: Session, app_model: App) -> dict: base_url = app_model.api_base_url + access_ready = _agent_app_access_ready(session, app_model) response = AgentApiAccessResponse( - enabled=bool(app_model.enable_api), + access_ready=access_ready, + enabled=bool(app_model.enable_api and access_ready), service_api_base_url=base_url, chat_endpoint=f"{base_url}/chat-messages", stop_endpoint=f"{base_url}/chat-messages/{{task_id}}/stop", diff --git a/api/controllers/console/apikey.py b/api/controllers/console/apikey.py index 01df6af5cb9..5179fc163e7 100644 --- a/api/controllers/console/apikey.py +++ b/api/controllers/console/apikey.py @@ -21,6 +21,7 @@ from models.dataset import Dataset from models.enums import ApiTokenType from models.model import ApiToken, App from services.api_token_service import ApiTokenCache +from services.app_service import AppService from . import console_ns from .wraps import ( @@ -103,7 +104,9 @@ class BaseApiKeyListResource(Resource): def _create_api_key(self, resource_id: str, current_tenant_id: str, *, session: Session) -> ApiToken: assert self.resource_id_field is not None, "resource_id_field must be set" - _get_resource(resource_id, current_tenant_id, self.resource_model, session=session) + resource = _get_resource(resource_id, current_tenant_id, self.resource_model, session=session) + if isinstance(resource, App): + AppService.ensure_agent_app_access_ready(resource, session=session) current_key_count: int = ( session.scalar( select(func.count(ApiToken.id)).where( diff --git a/api/core/app/apps/agent_app/app_generator.py b/api/core/app/apps/agent_app/app_generator.py index a4337db2a8f..a291b84b39b 100644 --- a/api/core/app/apps/agent_app/app_generator.py +++ b/api/core/app/apps/agent_app/app_generator.py @@ -27,6 +27,7 @@ from clients.agent_backend import AgentBackendRunEventAdapter from clients.agent_backend.factory import create_agent_backend_run_client from configs import dify_config from constants import UUID_NIL +from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot from core.app.app_config.easy_ui_based_app.model_config.converter import ModelConfigConverter from core.app.apps.agent_app.app_config_manager import AgentAppConfigManager from core.app.apps.agent_app.app_runner import AgentAppRunner @@ -58,7 +59,6 @@ from models.agent import ( AgentConfigSnapshot, AgentConfigVersionKind, AgentScope, - AgentSource, AgentStatus, AgentWorkingResourceStatus, AgentWorkspaceBinding, @@ -644,12 +644,6 @@ class AgentAppGenerator(MessageBasedAppGenerator): ) if agent is None: raise AgentAppGeneratorError("Agent App has no bound Agent") - if ( - agent.source == AgentSource.IMPORTED - and not agent.active_config_is_published - and invoke_from != InvokeFrom.DEBUGGER - ): - raise AgentAppNotPublishedError("Agent has not been published") if invoke_from == InvokeFrom.DEBUGGER: draft = self._resolve_debug_draft( tenant_id=app_model.tenant_id, @@ -664,9 +658,9 @@ class AgentAppGenerator(MessageBasedAppGenerator): "build_draft" if draft.draft_type == AgentConfigDraftType.DEBUG_BUILD else "draft" ) return agent, draft.id, config_version_kind, agent_soul - # active_config_is_published tracks whether the editable draft matches the active snapshot. - # Public runtime must keep serving the active snapshot even when unpublished draft edits exist. - if not agent.active_config_snapshot_id: + # Dirty drafts do not revoke a published snapshot, while the seeded + # create/import snapshot must never become public runtime configuration. + if not agent_has_workflow_callable_active_snapshot(session=session, agent=agent): raise AgentAppNotPublishedError("Agent has not been published") conversation_binding = self._resolve_conversation_binding( session=session, diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 545df2a320e..bca7cd812b8 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -13248,6 +13248,7 @@ Model class for AI model. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | +| access_ready | boolean | | Yes | | api_key_count | integer | | Yes | | api_rph | integer | | Yes | | api_rpm | integer | | Yes | @@ -13313,6 +13314,7 @@ Model class for AI model. | Name | Type | Description | Required | | ---- | ---- | ----------- | -------- | | access_mode | string | | No | +| access_ready | boolean | | No | | api_base_url | string | | No | | app_id | string | | No | | backing_app_id | string | | No | diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index 06b33a81b9d..13924aae4b1 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -9,7 +9,7 @@ from sqlalchemy.sql.elements import ColumnElement from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot from libs.helper import to_timestamp -from models import Account, Conversation +from models import Account, App, Conversation from models.agent import ( APP_BACKED_AGENT_SOURCES, Agent, @@ -623,6 +623,7 @@ class AgentComposerService: agent = cls._require_agent(session=session, tenant_id=tenant_id, agent_id=agent_id) if agent.scope != AgentScope.ROSTER or agent.source not in APP_BACKED_AGENT_SOURCES: raise AgentNotFoundError() + access_was_ready = agent_has_workflow_callable_active_snapshot(session=session, agent=agent) draft = cls._get_or_create_agent_draft( session=session, tenant_id=tenant_id, @@ -665,6 +666,22 @@ class AgentComposerService: agent.updated_by = account_id draft.base_snapshot_id = version.id draft.updated_by = account_id + if not access_was_ready: + if not agent.app_id: + raise AgentNotFoundError() + app = session.scalar( + select(App) + .where( + App.tenant_id == tenant_id, + App.id == agent.app_id, + ) + .limit(1) + ) + if app is None: + raise AgentNotFoundError() + app.enable_site = True + app.enable_api = True + app.updated_by = account_id session.flush() return { "result": "success", diff --git a/api/services/agent/errors.py b/api/services/agent/errors.py index b900bd857bb..45f10031491 100644 --- a/api/services/agent/errors.py +++ b/api/services/agent/errors.py @@ -29,6 +29,12 @@ class AgentModelNotConfiguredError(BaseHTTPException): code = 400 +class AgentAccessNotReadyError(BaseHTTPException): + error_code = "agent_not_published" + description = "Publish the Agent before enabling Web App or API access." + code = 409 + + class AgentBuildSandboxNotFoundError(BaseHTTPException): error_code = "agent_build_sandbox_not_found" description = "The retained Build Sandbox is no longer available." diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index 79b97429b4b..af7bed03879 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -1095,8 +1095,10 @@ class AgentRosterService: session=self._session, ) - target_app.enable_site = source_app.enable_site - target_app.enable_api = source_app.enable_api + # A copy owns a new publication history. It remains private until its + # first successful publish even when the source Agent is public. + target_app.enable_site = False + target_app.enable_api = False target_app.use_icon_as_answer_icon = source_app.use_icon_as_answer_icon target_app.tracing = source_app.tracing @@ -1164,7 +1166,7 @@ class AgentRosterService: target_version.version_note = source_version.version_note target_version.created_by = account_id target_agent.active_config_has_model = agent_soul_has_model(target_version.config_snapshot) - target_agent.active_config_is_published = source_agent.active_config_is_published + target_agent.active_config_is_published = False target_agent.updated_by = account_id def _next_duplicate_agent_name(self, *, tenant_id: str, base_name: str) -> str: diff --git a/api/services/app_dsl_service.py b/api/services/app_dsl_service.py index 0dd68611458..ee69f0bc7af 100644 --- a/api/services/app_dsl_service.py +++ b/api/services/app_dsl_service.py @@ -482,8 +482,8 @@ class AppDslService: app.icon_type = resolved_icon_type app.icon = icon app.icon_background = icon_background or app_data.get("icon_background", "#FFFFFF") - app.enable_site = True - app.enable_api = True + app.enable_site = app_mode != AppMode.AGENT + app.enable_api = app_mode != AppMode.AGENT app.use_icon_as_answer_icon = app_data.get("use_icon_as_answer_icon", False) app.created_by = account.id app.maintainer = account.id diff --git a/api/services/app_service.py b/api/services/app_service.py index de75b219ac2..1682de7e840 100644 --- a/api/services/app_service.py +++ b/api/services/app_service.py @@ -14,6 +14,7 @@ from sqlalchemy.orm import Session from configs import dify_config from constants.model_template import default_app_templates from core.agent.entities import AgentToolEntity +from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError from core.model_manager import ModelManager from core.tools.tool_manager import ToolManager @@ -38,7 +39,7 @@ from models.agent import ( from models.model import App, AppMode, AppModelConfig, IconType, Site, load_annotation_reply_config from models.tools import ApiToolProvider from models.workflow import Workflow -from services.agent.errors import AgentNameConflictError +from services.agent.errors import AgentAccessNotReadyError, AgentNameConflictError from services.agent.home_snapshot_service import AgentHomeSnapshotService from services.agent.retirement_service import WorkflowAgentRetirementService from services.agent.workspace_service import AgentWorkspaceService @@ -915,6 +916,30 @@ class AppService: return app + @staticmethod + def is_agent_app_access_ready(app: App, *, session: Session) -> bool: + """Return whether an Agent App has a publish-visible active snapshot.""" + + if app.mode != AppMode.AGENT: + return True + agent = session.scalar( + select(Agent) + .where( + Agent.tenant_id == app.tenant_id, + Agent.app_id == app.id, + Agent.scope == AgentScope.ROSTER, + Agent.source.in_(APP_BACKED_AGENT_SOURCES), + Agent.status == AgentStatus.ACTIVE, + ) + .limit(1) + ) + return bool(agent and agent_has_workflow_callable_active_snapshot(session=session, agent=agent)) + + @classmethod + def ensure_agent_app_access_ready(cls, app: App, *, session: Session) -> None: + if not cls.is_agent_app_access_ready(app, session=session): + raise AgentAccessNotReadyError() + def update_app_site_status(self, app: App, enable_site: bool, *, session: Session) -> App: """ Update app site status @@ -922,6 +947,8 @@ class AppService: :param enable_site: enable site status :return: App instance """ + if enable_site: + self.ensure_agent_app_access_ready(app, session=session) if enable_site == app.enable_site: return app assert current_user is not None @@ -941,6 +968,8 @@ class AppService: :param enable_api: enable api status :return: App instance """ + if enable_api: + self.ensure_agent_app_access_ready(app, session=session) if enable_api == app.enable_api: return app assert current_user is not None diff --git a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py index 5115b1189c3..d9bfb9decd1 100644 --- a/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py +++ b/api/tests/test_containers_integration_tests/services/test_app_dsl_service.py @@ -1201,6 +1201,10 @@ class TestAppDslService: ) assert imported_agent is not None assert imported_agent.active_config_is_published is False + imported_app = db_session_with_containers.get(App, result.app_id) + assert imported_app is not None + assert imported_app.enable_site is False + assert imported_app.enable_api is False draft = db_session_with_containers.scalar( select(AgentConfigDraft).where( AgentConfigDraft.agent_id == imported_agent.id, diff --git a/api/tests/unit_tests/controllers/common/test_agent_app_parameters.py b/api/tests/unit_tests/controllers/common/test_agent_app_parameters.py index 80ca42b0ddc..7b9e43143bc 100644 --- a/api/tests/unit_tests/controllers/common/test_agent_app_parameters.py +++ b/api/tests/unit_tests/controllers/common/test_agent_app_parameters.py @@ -8,7 +8,15 @@ from sqlalchemy.orm import Session from controllers.common.agent_app_parameters import get_published_agent_app_feature_dict_and_user_input_form from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError -from models.agent import Agent, AgentConfigSnapshot, AgentScope, AgentSource, AgentStatus +from models.agent import ( + Agent, + AgentConfigRevision, + AgentConfigRevisionOperation, + AgentConfigSnapshot, + AgentScope, + AgentSource, + AgentStatus, +) from models.model import AppAnnotationSetting @@ -55,6 +63,7 @@ def _persist_snapshot( tenant_id: str, agent_id: str, config_snapshot: dict[str, Any], + publish_visible: bool = True, ) -> AgentConfigSnapshot: snapshot = AgentConfigSnapshot( id=snapshot_id, @@ -65,13 +74,42 @@ def _persist_snapshot( config_snapshot=config_snapshot, ) session.add(snapshot) + if publish_visible: + _persist_publish_revision( + session, + snapshot_id=snapshot_id, + tenant_id=tenant_id, + agent_id=agent_id, + commit=False, + ) session.commit() return snapshot +def _persist_publish_revision( + session: Session, + *, + snapshot_id: str, + tenant_id: str, + agent_id: str, + commit: bool = True, +) -> None: + session.add( + AgentConfigRevision( + tenant_id=tenant_id, + agent_id=agent_id, + current_snapshot_id=snapshot_id, + revision=1, + operation=AgentConfigRevisionOperation.PUBLISH_DRAFT, + ) + ) + if commit: + session.commit() + + @pytest.mark.parametrize( "sqlite_session", - [(Agent, AgentConfigSnapshot, AppAnnotationSetting)], + [(Agent, AgentConfigSnapshot, AgentConfigRevision, AppAnnotationSetting)], indirect=True, ) def test_published_agent_app_parameters_use_soul_file_upload(sqlite_session: Session): @@ -137,7 +175,7 @@ def test_published_agent_app_parameters_use_soul_file_upload(sqlite_session: Ses assert parameters["user_input_form"] == [{"text-input": {"label": "topic", "variable": "topic", "required": True}}] -@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True) +@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot, AgentConfigRevision)], indirect=True) def test_published_agent_app_parameters_requires_bound_agent(sqlite_session: Session): tenant_id = _stable_uuid("tenant:unbound") app_model = _app_model(tenant_id=tenant_id, bound_agent_id=None) @@ -146,7 +184,7 @@ def test_published_agent_app_parameters_requires_bound_agent(sqlite_session: Ses get_published_agent_app_feature_dict_and_user_input_form(app_model, session=sqlite_session) -@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True) +@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot, AgentConfigRevision)], indirect=True) def test_published_agent_app_parameters_requires_existing_active_agent(sqlite_session: Session): requested_tenant_id = _stable_uuid("tenant:requested") agent_id = _stable_uuid("agent:cross-tenant") @@ -170,7 +208,7 @@ def test_published_agent_app_parameters_requires_existing_active_agent(sqlite_se False, ], ) -@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True) +@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot, AgentConfigRevision)], indirect=True) def test_published_agent_app_parameters_requires_published_agent( active_config_is_published: bool, sqlite_session: Session ): @@ -189,7 +227,7 @@ def test_published_agent_app_parameters_requires_published_agent( get_published_agent_app_feature_dict_and_user_input_form(app_model, session=sqlite_session) -@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True) +@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot, AgentConfigRevision)], indirect=True) def test_published_agent_app_parameters_allows_unpublished_draft_with_active_snapshot(sqlite_session: Session): tenant_id = _stable_uuid("tenant:unpublished-draft") agent_id = _stable_uuid("agent:unpublished-draft") @@ -219,7 +257,33 @@ def test_published_agent_app_parameters_allows_unpublished_draft_with_active_sna assert user_input_form == [] -@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True) +@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot, AgentConfigRevision)], indirect=True) +def test_published_agent_app_parameters_rejects_seeded_unpublished_snapshot(sqlite_session: Session): + tenant_id = _stable_uuid("tenant:never-published") + agent_id = _stable_uuid("agent:never-published") + snapshot_id = _stable_uuid("snapshot:never-published") + app_model = _app_model(tenant_id=tenant_id, bound_agent_id=agent_id) + _persist_agent( + sqlite_session, + tenant_id=tenant_id, + agent_id=agent_id, + active_config_snapshot_id=snapshot_id, + active_config_is_published=False, + ) + _persist_snapshot( + sqlite_session, + snapshot_id=snapshot_id, + tenant_id=tenant_id, + agent_id=agent_id, + config_snapshot={}, + publish_visible=False, + ) + + with pytest.raises(AgentAppNotPublishedError, match="not been published"): + get_published_agent_app_feature_dict_and_user_input_form(app_model, session=sqlite_session) + + +@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot, AgentConfigRevision)], indirect=True) def test_published_agent_app_parameters_requires_published_snapshot(sqlite_session: Session): tenant_id = _stable_uuid("tenant:missing-snapshot") agent_id = _stable_uuid("agent:missing-snapshot") @@ -231,12 +295,18 @@ def test_published_agent_app_parameters_requires_published_snapshot(sqlite_sessi active_config_snapshot_id=_stable_uuid("snapshot:missing"), active_config_is_published=True, ) + _persist_publish_revision( + sqlite_session, + snapshot_id=_stable_uuid("snapshot:missing"), + tenant_id=tenant_id, + agent_id=agent_id, + ) with pytest.raises(AgentAppGeneratorError, match="published version not found"): get_published_agent_app_feature_dict_and_user_input_form(app_model, session=sqlite_session) -@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot)], indirect=True) +@pytest.mark.parametrize("sqlite_session", [(Agent, AgentConfigSnapshot, AgentConfigRevision)], indirect=True) def test_published_agent_app_parameters_allows_missing_legacy_app_model_config(sqlite_session: Session): tenant_id = _stable_uuid("tenant:no-legacy-config") agent_id = _stable_uuid("agent:no-legacy-config") diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index a56f33ffff2..6f2454b4dcb 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -459,6 +459,11 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id( "get_system_features", lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), ) + monkeypatch.setattr( + roster_controller, + "agent_has_workflow_callable_active_snapshot", + lambda **_kwargs: False, + ) class FakeAppService: def get_app(self, app_obj: object, *, session: object) -> object: @@ -482,6 +487,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id( assert detail["debug_conversation_has_messages"] is True assert detail["debug_conversation_message_count"] == 2 assert detail["role"] == "Resolved role" + assert detail["access_ready"] is False assert "active_config_is_published" not in detail assert "bound_agent_id" not in detail assert captured["get_app"] == {"app": app_model, "session": session} @@ -699,12 +705,19 @@ def test_agent_publish_and_build_draft_routes_call_composer_service( def test_agent_api_access_uses_agent_id_and_returns_service_api_metadata(monkeypatch: pytest.MonkeyPatch) -> None: agent_id = "00000000-0000-0000-0000-000000000001" app_model = SimpleNamespace( - id="app-1", enable_api=True, api_base_url="https://api.example.test/v1", api_rpm=60, api_rph=600 + id="app-1", + tenant_id="tenant-1", + enable_api=True, + api_base_url="https://api.example.test/v1", + api_rpm=60, + api_rph=600, ) monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", lambda _session, **kwargs: app_model) monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda _session, app_id: 2) + monkeypatch.setattr(roster_controller, "_agent_app_access_ready", lambda _session, _app: True) response = unwrap(AgentApiAccessApi.get)(AgentApiAccessApi(), MagicMock(), "tenant-1", agent_id) assert response == { + "access_ready": True, "enabled": True, "service_api_base_url": "https://api.example.test/v1", "streaming_only": True, @@ -726,13 +739,19 @@ def test_agent_api_status_and_key_routes_resolve_backing_app(app: Flask, monkeyp agent_id = "00000000-0000-0000-0000-000000000001" api_key_id = "00000000-0000-0000-0000-000000000002" app_model = SimpleNamespace( - id="app-1", enable_api=False, api_base_url="https://api.example.test/v1", api_rpm=0, api_rph=0 + id="app-1", + tenant_id="tenant-1", + enable_api=False, + api_base_url="https://api.example.test/v1", + api_rpm=0, + api_rph=0, ) captured: dict[str, object] = {} session = MagicMock() resolve_app = Mock(return_value=app_model) monkeypatch.setattr(roster_controller, "_resolve_agent_app_model", resolve_app) monkeypatch.setattr(roster_controller, "_agent_api_key_count", lambda _session, app_id: 1) + monkeypatch.setattr(roster_controller, "_agent_app_access_ready", lambda _session, _app: True) class FakeAppService: def update_app_api_status(self, app_obj: object, enable_api: bool, *, session: object) -> object: @@ -1363,7 +1382,6 @@ def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id( def test_agent_chat_stream_preflight_raises_first_error_event() -> None: - class ClosableStream: def __init__(self) -> None: self.closed = False @@ -1492,7 +1510,6 @@ def test_build_chat_finalization_helper_forces_debug_build_and_push_prompt( def test_drain_streaming_generate_response_returns_on_message_end() -> None: - class ClosableResponse: def __init__(self) -> None: self._chunks = iter( diff --git a/api/tests/unit_tests/controllers/console/test_apikey.py b/api/tests/unit_tests/controllers/console/test_apikey.py index 0435ad0996a..36f1935caf2 100644 --- a/api/tests/unit_tests/controllers/console/test_apikey.py +++ b/api/tests/unit_tests/controllers/console/test_apikey.py @@ -13,7 +13,8 @@ from controllers.console.apikey import BaseApiKeyListResource, BaseApiKeyResourc from models import Account from models.account import AccountStatus, TenantAccountRole from models.enums import ApiTokenType -from models.model import ApiToken, App +from models.model import ApiToken, App, AppMode +from services.agent.errors import AgentAccessNotReadyError def _make_list_resource() -> BaseApiKeyListResource: @@ -110,6 +111,24 @@ def test_create_api_key_uses_injected_session_and_tenant_id() -> None: session.commit.assert_called_once() +def test_create_agent_api_key_requires_published_access() -> None: + resource = _make_list_resource() + app = App(id="app-1", tenant_id="tenant-1", mode=AppMode.AGENT) + session = MagicMock() + session.execute.return_value.scalar_one_or_none.return_value = app + + with patch( + "controllers.console.apikey.AppService.ensure_agent_app_access_ready", + side_effect=AgentAccessNotReadyError(), + ) as ensure_access_ready: + with pytest.raises(AgentAccessNotReadyError): + resource._create_api_key("app-1", "tenant-1", session=session) + + ensure_access_ready.assert_called_once_with(app, session=session) + session.scalar.assert_not_called() + session.add.assert_not_called() + + def test_delete_api_key_rejects_non_admin_account() -> None: resource = _make_key_resource() raw_delete = cast( diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py b/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py index d8397fe3a55..37ea3338000 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py @@ -13,6 +13,7 @@ from unittest.mock import MagicMock import pytest +from core.app.apps.agent_app import app_generator from core.app.apps.agent_app.app_generator import AgentAppGenerator, AgentAppGeneratorError, AgentAppNotPublishedError from core.app.entities.app_invoke_entities import InvokeFrom from models.agent import AgentConfigDraft, AgentConfigDraftType, AgentConfigVersionKind, AgentScope, AgentSource @@ -238,6 +239,19 @@ class TestResolveDebugDraft: class TestResolveAgent: + @pytest.fixture(autouse=True) + def _publish_visibility(self, monkeypatch: pytest.MonkeyPatch) -> None: + def is_publish_visible(*, agent: SimpleNamespace, **_kwargs: object) -> bool: + if "publish_visible" in vars(agent): + return bool(agent.publish_visible) + return bool(agent.active_config_is_published) + + monkeypatch.setattr( + app_generator, + "agent_has_workflow_callable_active_snapshot", + is_publish_visible, + ) + def test_success_chains_to_resolve_by_id(self): bound_agent = SimpleNamespace( id="agent-1", @@ -270,6 +284,7 @@ class TestResolveAgent: source=AgentSource.AGENT_APP, active_config_snapshot_id="snap-1", active_config_is_published=False, + publish_visible=True, ) inner_agent = SimpleNamespace(id="agent-1") snapshot = _snapshot() @@ -428,6 +443,24 @@ class TestResolveAgent: session=session, ) # type: ignore[arg-type] + def test_never_published_agent_app_is_not_available_to_public_runtime(self): + bound_agent = SimpleNamespace( + id="agent-1", + source=AgentSource.AGENT_APP, + active_config_snapshot_id="snap-1", + active_config_is_published=False, + publish_visible=False, + ) + + with pytest.raises(AgentAppNotPublishedError, match="not been published"): + AgentAppGenerator()._resolve_agent( + SimpleNamespace(id="app-1", tenant_id="t1"), + invoke_from=InvokeFrom.WEB_APP, + draft_type=None, + user=SimpleNamespace(id="user-1"), + session=_FakeScalarSession([bound_agent]), + ) # type: ignore[arg-type] + def test_unpublished_imported_agent_remains_available_to_debugger(self): bound_agent = SimpleNamespace( id="agent-1", diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index ecdb220424c..479324c3167 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -856,6 +856,7 @@ def test_publish_agent_app_draft_rejects_missing_model(monkeypatch: pytest.Monke scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, + app_id="app-1", active_config_snapshot_id="version-1", active_config_is_published=False, ) @@ -877,6 +878,7 @@ def test_publish_agent_app_draft_rejects_missing_model(monkeypatch: pytest.Monke raise AssertionError("knowledge datasets must not be validated when Agent Soul has no model") monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", lambda payload: None) + monkeypatch.setattr(composer_service, "agent_has_workflow_callable_active_snapshot", lambda **_kwargs: False) monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", fail_validate_knowledge_datasets) monkeypatch.setattr(AgentComposerService, "_create_config_version", fail_create_config_version) @@ -908,6 +910,7 @@ def test_publish_agent_app_draft_creates_published_snapshot(monkeypatch: pytest. scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, + app_id="app-1", active_config_snapshot_id="version-1", ) draft = AgentConfigDraft( @@ -920,12 +923,16 @@ def test_publish_agent_app_draft_creates_published_snapshot(monkeypatch: pytest. config_snapshot=_agent_soul_with_model(), ) version = SimpleNamespace(id="version-2") - session.add_all([agent, draft]) + app = _app(mode=AppMode.AGENT) + app.enable_site = False + app.enable_api = False + session.add_all([agent, draft, app]) session.commit() created: dict[str, object] = {} calls: list[str] = [] monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", lambda payload: None) + monkeypatch.setattr(composer_service, "agent_has_workflow_callable_active_snapshot", lambda **_kwargs: False) monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", lambda **kwargs: None) monkeypatch.setattr( composer_service, @@ -957,6 +964,9 @@ def test_publish_agent_app_draft_creates_published_snapshot(monkeypatch: pytest. assert agent.active_config_snapshot_id == "version-2" assert agent.active_config_has_model is True assert agent.active_config_is_published is True + assert app.enable_site is True + assert app.enable_api is True + assert app.updated_by == "account-1" def test_repeated_publish_reuses_normal_draft_home_without_creating_resources( @@ -973,6 +983,7 @@ def test_repeated_publish_reuses_normal_draft_home_without_creating_resources( scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, + app_id="app-1", active_config_snapshot_id="version-1", ) draft = AgentConfigDraft( @@ -984,12 +995,21 @@ def test_repeated_publish_reuses_normal_draft_home_without_creating_resources( home_snapshot_id="home-1", config_snapshot=_agent_soul_with_model(), ) - session.add_all([agent, draft]) + app = _app(mode=AppMode.AGENT) + app.enable_site = False + app.enable_api = False + session.add_all([agent, draft, app]) session.commit() published_homes: list[str] = [] versions = iter([SimpleNamespace(id="version-2"), SimpleNamespace(id="version-3")]) create_from_build = MagicMock() monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", lambda _payload: None) + publish_visibility = iter([False, True]) + monkeypatch.setattr( + composer_service, + "agent_has_workflow_callable_active_snapshot", + lambda **_kwargs: next(publish_visibility), + ) monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", lambda **_kwargs: None) monkeypatch.setattr(composer_service, "validate_home_snapshot_binding", lambda **_kwargs: None) monkeypatch.setattr( @@ -1007,6 +1027,8 @@ def test_repeated_publish_reuses_normal_draft_home_without_creating_resources( agent_id="agent-1", account_id="account-1", ) + app.enable_site = False + app.enable_api = False second = AgentComposerService.publish_agent_app_draft( session=session, tenant_id="tenant-1", @@ -1018,6 +1040,8 @@ def test_repeated_publish_reuses_normal_draft_home_without_creating_resources( assert second["active_config_snapshot_id"] == "version-3" assert published_homes == ["home-1", "home-1"] assert draft.home_snapshot_id == "home-1" + assert app.enable_site is False + assert app.enable_api is False create_from_build.assert_not_called() @@ -5264,8 +5288,8 @@ class TestAgentAppBackingAgent: id="target-app", app_model_config=target_config, app_model_config_with_session=lambda *, session: target_config, - enable_site=True, - enable_api=True, + enable_site=False, + enable_api=False, use_icon_as_answer_icon=False, tracing=None, ) @@ -5363,7 +5387,7 @@ class TestAgentAppBackingAgent: assert params.mode == "agent" assert params.agent_role == "Analyst" assert target_app.enable_site is False - assert target_app.enable_api is True + assert target_app.enable_api is False assert target_app.use_icon_as_answer_icon is True assert target_app.tracing == "{}" assert target_config.opening_statement == "hello" @@ -5377,6 +5401,7 @@ class TestAgentAppBackingAgent: assert target_version.summary == "configured" assert target_version.version_note == "v1" assert target_agent.active_config_has_model is True + assert target_agent.active_config_is_published is False assert target_agent.updated_by == "account-1" assert session.get(Agent, target_agent.id) is target_agent diff --git a/api/tests/unit_tests/services/test_app_service.py b/api/tests/unit_tests/services/test_app_service.py index 66ae199a067..853838fdd55 100644 --- a/api/tests/unit_tests/services/test_app_service.py +++ b/api/tests/unit_tests/services/test_app_service.py @@ -16,7 +16,7 @@ from graphon.model_runtime.entities.model_entities import ModelType from models import Account, Tenant from models.model import App, AppMode, AppModelConfig, IconType from models.workflow import Workflow -from services.agent.errors import AgentNameConflictError +from services.agent.errors import AgentAccessNotReadyError, AgentNameConflictError from services.app_service import AppListParams, AppService, CreateAppParams @@ -109,7 +109,7 @@ class TestCreateAppTransactionBoundary: [AppService.update_app_site_status, AppService.update_app_api_status], ) def test_app_status_updates_commit_before_signal(update_status: Callable[..., App]) -> None: - app = cast(App, SimpleNamespace(enable_site=False, enable_api=False)) + app = cast(App, SimpleNamespace(enable_site=False, enable_api=False, mode=AppMode.CHAT)) session = MagicMock() phase_events: list[str] = [] session.commit.side_effect = lambda: phase_events.append("commit") @@ -123,6 +123,36 @@ def test_app_status_updates_commit_before_signal(update_status: Callable[..., Ap assert phase_events == ["commit", "signal"] +@pytest.mark.parametrize( + "update_status", + [ + AppService.update_app_site_status, + AppService.update_app_api_status, + ], +) +def test_unpublished_agent_app_access_cannot_be_enabled(update_status: Callable[..., App]) -> None: + app = cast( + App, + SimpleNamespace( + id="app-1", + tenant_id="tenant-1", + mode=AppMode.AGENT, + enable_site=False, + enable_api=False, + ), + ) + session = MagicMock() + session.scalar.return_value = SimpleNamespace(id="agent-1") + + with patch("services.app_service.agent_has_workflow_callable_active_snapshot", return_value=False): + with pytest.raises(AgentAccessNotReadyError): + update_status(AppService(), app, True, session=session) + + assert app.enable_site is False + assert app.enable_api is False + session.commit.assert_not_called() + + class TestOpenapiVisibilityHelpers: """Coverage for the session-injected, openapi-visibility-scoped ``AppService`` getters used by ``/openapi/v1/apps*``. These helpers @@ -411,6 +441,8 @@ class TestAgentAppType: # Runtime config comes from the Agent Soul, so no model_config is seeded. assert "model_config" not in default_app_templates[AppMode.AGENT] assert default_app_templates[AppMode.AGENT]["app"]["mode"] == AppMode.AGENT + assert default_app_templates[AppMode.AGENT]["app"]["enable_site"] is False + assert default_app_templates[AppMode.AGENT]["app"]["enable_api"] is False def test_create_app_params_accepts_agent_mode(self): from services.app_service import CreateAppParams diff --git a/e2e/features/agent-v2/access-point.feature b/e2e/features/agent-v2/access-point.feature index 53a775739b0..2870e5270f9 100644 --- a/e2e/features/agent-v2/access-point.feature +++ b/e2e/features/agent-v2/access-point.feature @@ -1,18 +1,18 @@ @agent-v2 @authenticated @access-point Feature: Agent v2 Access Point @core - Scenario: Access Point shows the available Agent v2 access surfaces + Scenario: Access Point keeps unpublished Agent v2 access unavailable Given I am signed in as the default E2E admin And an Agent v2 test agent has been created via API When I open the Agent v2 configure page from the Agent Roster And I switch to the Agent v2 Access Point section - Then I should see the Agent v2 Access Point overview + Then the unpublished Agent v2 access surfaces should be unavailable @core @web-app-access Scenario: Web app access URL can be copied without changing orchestration Given I am signed in as the default E2E admin And a basic configured Agent v2 test agent has been created via API - And Agent v2 Web app access has been enabled via API + And the Agent v2 draft has been published via API When I open the Agent v2 configure page from the Agent Roster And I switch to the Agent v2 Access Point section Then I should see the Agent v2 Web app access URL @@ -25,7 +25,6 @@ Feature: Agent v2 Access Point Given I am signed in as the default E2E admin And a basic configured Agent v2 test agent has been created via API And the Agent v2 draft has been published via API - And Agent v2 Web app access has been enabled via API When I open the Agent v2 configure page from the Agent Roster And I switch to the Agent v2 Access Point section Then I should see the Agent v2 Web app access URL @@ -37,7 +36,7 @@ Feature: Agent v2 Access Point Scenario: Web app Embedded configuration opens from Access Point Given I am signed in as the default E2E admin And a basic configured Agent v2 test agent has been created via API - And Agent v2 Web app access has been enabled via API + And the Agent v2 draft has been published via API When I open the Agent v2 configure page from the Agent Roster And I switch to the Agent v2 Access Point section And I open Agent v2 Embedded configuration @@ -48,7 +47,7 @@ Feature: Agent v2 Access Point Scenario: Web app customization opens from Access Point Given I am signed in as the default E2E admin And a basic configured Agent v2 test agent has been created via API - And Agent v2 Web app access has been enabled via API + And the Agent v2 draft has been published via API When I open the Agent v2 configure page from the Agent Roster And I switch to the Agent v2 Access Point section And I open Agent v2 Web app customization @@ -59,7 +58,7 @@ Feature: Agent v2 Access Point Scenario: Web app settings open from Access Point without changing orchestration Given I am signed in as the default E2E admin And a basic configured Agent v2 test agent has been created via API - And Agent v2 Web app access has been enabled via API + And the Agent v2 draft has been published via API When I open the Agent v2 configure page from the Agent Roster And I switch to the Agent v2 Access Point section And I open Agent v2 Web app settings @@ -71,15 +70,15 @@ Feature: Agent v2 Access Point Given I am signed in as the default E2E admin And a basic configured Agent v2 test agent has been created via API And the Agent v2 draft has been published via API - And Agent v2 Web app access has been enabled via API When I open the Agent v2 configure page from the Agent Roster And I switch to the Agent v2 Access Point section And I disable Agent v2 Web app access Then Agent v2 Web app access should be out of service + When I republish the Agent v2 draft via API + And I refresh the current page + Then Agent v2 Web app access should be out of service When I enable Agent v2 Web app access Then Agent v2 Web app access should be in service - When I refresh the current page - Then Agent v2 Web app access should be in service @core @prepared @workflow-reference Scenario: Workflow access shows the referencing workflow @@ -96,7 +95,7 @@ Feature: Agent v2 Access Point Scenario: Backend service API endpoint can be copied Given I am signed in as the default E2E admin And an Agent v2 test agent has been created via API - And Agent v2 Backend service API access has been enabled via API + And the Agent v2 draft has been published via API When I open the Agent v2 configure page from the Agent Roster And I switch to the Agent v2 Access Point section Then I should see the Agent v2 Backend service API endpoint @@ -107,7 +106,8 @@ Feature: Agent v2 Access Point Scenario: Backend service API keys are managed without exposing existing secrets Given I am signed in as the default E2E admin And an Agent v2 test agent has been created via API - And Agent v2 Backend service API access has been enabled with a key via API + And the Agent v2 draft has been published via API + And an Agent v2 Backend service API key has been created via API When I open the Agent v2 configure page from the Agent Roster And I switch to the Agent v2 Access Point section And I open Agent v2 API key management @@ -123,7 +123,7 @@ Feature: Agent v2 Access Point Scenario: Backend service API Reference opens from Access Point Given I am signed in as the default E2E admin And an Agent v2 test agent has been created via API - And Agent v2 Backend service API access has been enabled via API + And the Agent v2 draft has been published via API When I open the Agent v2 configure page from the Agent Roster And I switch to the Agent v2 Access Point section And I open the Agent v2 API Reference @@ -133,7 +133,7 @@ Feature: Agent v2 Access Point Scenario: Backend service API access can be disabled and restored from Access Point Given I am signed in as the default E2E admin And an Agent v2 test agent has been created via API - And Agent v2 Backend service API access has been enabled via API + And the Agent v2 draft has been published via API When I open the Agent v2 configure page from the Agent Roster And I switch to the Agent v2 Access Point section And I disable Agent v2 Backend service API access @@ -149,10 +149,10 @@ Feature: Agent v2 Access Point And the Agent Builder stable chat model is available And the Agent v2 runtime backend is available And a runnable Agent v2 test agent has been created via API - And Agent v2 Backend service API access has been enabled with a key via API When I open the Agent v2 configure page And I publish the Agent v2 draft Then the Agent v2 draft should be published and up to date + Given an Agent v2 Backend service API key has been created via API When I send the Agent v2 Backend service API minimal request Then the Agent v2 Backend service API request should succeed with the normal E2E marker @@ -163,7 +163,7 @@ Feature: Agent v2 Access Point And the Agent v2 runtime backend is available And a runnable Agent v2 test agent has been created via API And the Agent v2 draft has been published via API - And Agent v2 Backend service API access has been enabled with a key via API + And an Agent v2 Backend service API key has been created via API When I open the Agent v2 configure page from the Agent Roster And I switch to the Agent v2 Access Point section And I disable Agent v2 Backend service API access diff --git a/e2e/features/agent-v2/knowledge.feature b/e2e/features/agent-v2/knowledge.feature index be8796fc299..20184bf697c 100644 --- a/e2e/features/agent-v2/knowledge.feature +++ b/e2e/features/agent-v2/knowledge.feature @@ -31,13 +31,13 @@ Feature: Agent v2 Knowledge Retrieval And the Agent v2 runtime backend is available And the Agent Builder preseeded dataset "E2E Agent Knowledge Base" is indexed and ready And a runnable Agent v2 test agent using the agent-decision model has been created via API - And Agent v2 Backend service API access has been enabled with a key via API When I open the Agent v2 configure page And I add the Agent Builder knowledge base as an Agent decide Knowledge Retrieval Then the Agent v2 Agent decide Knowledge Retrieval should be saved in the Agent v2 draft And the Agent v2 configuration should be saved automatically When I publish the Agent v2 draft Then the Agent v2 draft should be published and up to date + Given an Agent v2 Backend service API key has been created via API When I send the Agent v2 Backend service API knowledge request Then the Agent v2 Backend service API response should include the knowledge E2E marker @@ -48,13 +48,13 @@ Feature: Agent v2 Knowledge Retrieval And the Agent v2 runtime backend is available And the Agent Builder preseeded dataset "E2E Agent Knowledge Base" is indexed and ready And a runnable Agent v2 test agent has been created via API - And Agent v2 Backend service API access has been enabled with a key via API When I open the Agent v2 configure page And I add the Agent Builder knowledge base as a Custom query Knowledge Retrieval Then the Agent v2 Custom query Knowledge Retrieval should be saved in the Agent v2 draft And the Agent v2 configuration should be saved automatically When I publish the Agent v2 draft Then the Agent v2 draft should be published and up to date + Given an Agent v2 Backend service API key has been created via API When I send the Agent v2 Backend service API knowledge request Then the Agent v2 Backend service API response should include the knowledge E2E marker diff --git a/e2e/features/agent-v2/publish.feature b/e2e/features/agent-v2/publish.feature index 88a84dc9381..30a8eabb799 100644 --- a/e2e/features/agent-v2/publish.feature +++ b/e2e/features/agent-v2/publish.feature @@ -18,6 +18,9 @@ Feature: Agent v2 publish When I open the Agent v2 configure page And I publish the Agent v2 draft Then the Agent v2 draft should be published and up to date + When I switch to the Agent v2 Access Point section + Then Agent v2 Web app access should be in service + And Agent v2 Backend service API access should be in service @core @prepared @stable-model Scenario: Publish action follows unpublished changes @@ -61,7 +64,6 @@ Feature: Agent v2 publish And the Agent Builder stable chat model is available And the Agent v2 runtime backend is available And a runnable Agent v2 test agent has been created via API - And Agent v2 Web app access has been enabled via API When I open the Agent v2 configure page And I publish the Agent v2 draft Then the Agent v2 draft should be published and up to date @@ -76,7 +78,6 @@ Feature: Agent v2 publish And the Agent Builder stable chat model is available And the Agent v2 runtime backend is available And a runnable Agent v2 test agent has been created via API - And Agent v2 Web app access has been enabled via API When I open the Agent v2 configure page And I publish the Agent v2 draft Then the Agent v2 draft should be published and up to date @@ -95,7 +96,6 @@ Feature: Agent v2 publish And the Agent Builder stable chat model is available And the Agent v2 runtime backend is available And a runnable Agent v2 test agent has been created via API - And Agent v2 Web app access has been enabled via API When I open the Agent v2 configure page And I publish the Agent v2 draft Then the Agent v2 draft should be published and up to date diff --git a/e2e/features/agent-v2/support/access-point.ts b/e2e/features/agent-v2/support/access-point.ts index 575cef3e662..014e4a3c53d 100644 --- a/e2e/features/agent-v2/support/access-point.ts +++ b/e2e/features/agent-v2/support/access-point.ts @@ -1,6 +1,5 @@ import type { AgentAppDetailWithSite } from '@dify/contracts/api/console/agent/types.gen' import type { ChatRequestPayloadWithUser } from '@dify/contracts/api/service/types.gen' -import type { ConsoleClient } from '../../../support/api/console-client' import { consumeServiceApiSse, SERVICE_API_STREAM_TIMEOUT_MS } from './service-api-sse' export type AgentServiceApiChatResult = { @@ -41,19 +40,6 @@ export function getAgentWebAppURL(agent: AgentAppDetailWithSite): string { return `${baseURL.replace(/\/$/, '')}/agent/${token}` } -export async function enableAgentWebApp(client: ConsoleClient, agentId: string): Promise { - const agent = await client.agent.byAgentId.get({ params: { agent_id: agentId } }) - const appId = agent.app_id ?? agent.backing_app_id - if (!appId) throw new Error(`Agent v2 ${agentId} does not expose a backing app ID.`) - - await client.apps.byAppId.siteEnable.post({ - body: { enable_site: true }, - params: { app_id: appId }, - }) - const updatedAgent = await client.agent.byAgentId.get({ params: { agent_id: agentId } }) - return getAgentWebAppURL(updatedAgent) -} - export async function sendAgentServiceApiChatMessage({ apiKey, query = 'Please reply with the test success marker.', diff --git a/e2e/features/agent-v2/tools.feature b/e2e/features/agent-v2/tools.feature index 262389b8f69..c84f4274148 100644 --- a/e2e/features/agent-v2/tools.feature +++ b/e2e/features/agent-v2/tools.feature @@ -19,10 +19,10 @@ Feature: Agent v2 tools And the Agent Builder stable chat model is available And the Agent Builder preseeded tool "JSON Process / JSON Replace" is available And a runnable Agent v2 test agent with the JSON Replace tool has been created via API - And Agent v2 Backend service API access has been enabled with a key via API When I open the Agent v2 configure page Then the Agent v2 JSON Replace tool should be saved in the Agent v2 draft When I publish the Agent v2 draft Then the Agent v2 draft should be published and up to date + Given an Agent v2 Backend service API key has been created via API When I send the Agent v2 Backend service API JSON Replace request Then the Agent v2 Backend service API response should include the JSON Replace E2E marker diff --git a/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts b/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts index c6f62ccc6f8..77aab73f1cc 100644 --- a/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point-service-api.steps.ts @@ -9,13 +9,10 @@ import { import { SERVICE_API_RUNTIME_STEP_TIMEOUT_MS } from '../../agent-v2/support/service-api-sse' import { getCurrentAgentId, getServiceApiCard } from './access-point-helpers' -async function enableAgentApiAccessWithKey(world: DifyWorld) { +async function createAgentApiKey(world: DifyWorld) { const agentId = getCurrentAgentId(world) const client = world.getConsoleClient() - const apiAccess = await client.agent.byAgentId.apiEnable.post({ - body: { enable_api: true }, - params: { agent_id: agentId }, - }) + const apiAccess = await client.agent.byAgentId.apiAccess.get({ params: { agent_id: agentId } }) const apiKey = await client.agent.byAgentId.apiKeys.post({ params: { agent_id: agentId } }) world.agentBuilder.accessPoint.serviceApiBaseURL = apiAccess.service_api_base_url @@ -23,25 +20,25 @@ async function enableAgentApiAccessWithKey(world: DifyWorld) { } Given( - 'Agent v2 Backend service API access has been enabled with a key via API', + 'an Agent v2 Backend service API key has been created via API', async function (this: DifyWorld) { - await enableAgentApiAccessWithKey(this) + await createAgentApiKey(this) }, ) Then('I should see the Agent v2 Backend service API endpoint', async function (this: DifyWorld) { const serviceApiCard = getServiceApiCard(this) - - if (!this.agentBuilder.accessPoint.serviceApiBaseURL) - throw new Error('No Agent v2 service API endpoint found. Enable Backend service API first.') + const agentId = getCurrentAgentId(this) + const apiAccess = await this.getConsoleClient().agent.byAgentId.apiAccess.get({ + params: { agent_id: agentId }, + }) + this.agentBuilder.accessPoint.serviceApiBaseURL = apiAccess.service_api_base_url await expect(serviceApiCard.getByRole('heading', { name: 'Backend service API' })).toBeVisible({ timeout: 30_000, }) await expect(serviceApiCard.getByText('Service API Endpoint')).toBeVisible() - await expect( - serviceApiCard.getByText(this.agentBuilder.accessPoint.serviceApiBaseURL), - ).toBeVisible() + await expect(serviceApiCard.getByText(apiAccess.service_api_base_url)).toBeVisible() await expect(serviceApiCard.getByLabel('Copy service API endpoint')).toBeEnabled() }) diff --git a/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts b/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts index 9182bc7ad5a..9712b44b7a4 100644 --- a/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point-web-app.steps.ts @@ -2,6 +2,7 @@ import type { Page } from '@playwright/test' import type { DifyWorld } from '../../support/world' import { Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' +import { getAgentWebAppURL } from '../../agent-v2/support/access-point' import { agentBuilderExpectedTokens } from '../../agent-v2/support/agent-builder-resources' import { getCurrentAgentId, getDialog, getWebAppCard } from './access-point-helpers' @@ -48,8 +49,9 @@ When('I launch the Agent v2 Web app', async function (this: DifyWorld) { }) When('I open the Agent v2 Web app URL', async function (this: DifyWorld) { - const webAppURL = this.agentBuilder.accessPoint.webAppURL - if (!webAppURL) throw new Error('No Agent v2 Web app URL was recorded.') + const agentId = getCurrentAgentId(this) + const agent = await this.getConsoleClient().agent.byAgentId.get({ params: { agent_id: agentId } }) + const webAppURL = this.agentBuilder.accessPoint.webAppURL ?? getAgentWebAppURL(agent) if (!this.context) throw new Error('Playwright browser context has not been initialized.') const webAppPage = await this.context.newPage() diff --git a/e2e/features/step-definitions/agent-v2/access-point.steps.ts b/e2e/features/step-definitions/agent-v2/access-point.steps.ts index 79b0f06aa40..1490f252cfa 100644 --- a/e2e/features/step-definitions/agent-v2/access-point.steps.ts +++ b/e2e/features/step-definitions/agent-v2/access-point.steps.ts @@ -2,38 +2,22 @@ import type { DifyWorld } from '../../support/world' import type { AccessSurfaceName } from './access-point-helpers' import { Given, Then, When } from '@cucumber/cucumber' import { expect } from '@playwright/test' -import { enableAgentWebApp } from '../../agent-v2/support/access-point' import { publishAgentWithPublishableDraft } from '../../agent-v2/support/agent' import { - getAccessRegion, getAccessSurfaceCard, getCurrentAgentId, getPreseededResource, + getServiceApiCard, + getWebAppCard, } from './access-point-helpers' Given('the Agent v2 draft has been published via API', async function (this: DifyWorld) { await publishAgentWithPublishableDraft(this.getConsoleClient(), getCurrentAgentId(this)) }) -Given( - /^Agent v2 (Web app|Backend service API) access has been enabled via API$/, - async function (this: DifyWorld, surface: AccessSurfaceName) { - if (surface === 'Web app') { - this.agentBuilder.accessPoint.webAppURL = await enableAgentWebApp( - this.getConsoleClient(), - getCurrentAgentId(this), - ) - return - } - - const agentId = getCurrentAgentId(this) - const apiAccess = await this.getConsoleClient().agent.byAgentId.apiEnable.post({ - body: { enable_api: true }, - params: { agent_id: agentId }, - }) - this.agentBuilder.accessPoint.serviceApiBaseURL = apiAccess.service_api_base_url - }, -) +When('I republish the Agent v2 draft via API', async function (this: DifyWorld) { + await publishAgentWithPublishableDraft(this.getConsoleClient(), getCurrentAgentId(this)) +}) When( 'I open the preseeded Agent v2 Access Point page for {string} from the Agent Roster', @@ -61,34 +45,20 @@ When('I switch to the Agent v2 Access Point section', async function (this: Dify await expect(page.getByRole('region', { name: 'Access Point' })).toBeVisible() }) -Then('I should see the Agent v2 Access Point overview', async function (this: DifyWorld) { - const accessRegion = getAccessRegion(this) +Then( + 'the unpublished Agent v2 access surfaces should be unavailable', + async function (this: DifyWorld) { + const webAppCard = getWebAppCard(this) + const serviceApiCard = getServiceApiCard(this) - await expect(accessRegion).toBeVisible({ timeout: 30_000 }) - await expect(accessRegion.getByRole('heading', { name: 'Access Point' })).toBeVisible() - await expect(accessRegion.getByRole('heading', { name: 'Web app' })).toBeVisible() - await expect(accessRegion.getByText('Access URL')).toBeVisible() - await expect(accessRegion.getByLabel('Copy access URL')).toBeVisible() - await expect(accessRegion.getByLabel('Toggle Web app access')).toBeVisible() - await expect(accessRegion.getByRole('link', { name: 'Launch' })).toBeVisible() - await expect(accessRegion.getByRole('button', { name: 'Embedded' })).toBeVisible() - await expect(accessRegion.getByRole('button', { name: 'Custom Frontend' })).toBeVisible() - await expect(accessRegion.getByRole('button', { name: 'Branding' })).toBeVisible() - await expect(accessRegion.getByRole('heading', { name: 'Backend service API' })).toBeVisible() - await expect(accessRegion.getByText('Service API Endpoint')).toBeVisible() - await expect(accessRegion.getByLabel('Copy service API endpoint')).toBeVisible() - await expect(accessRegion.getByLabel('Toggle Backend service API access')).toBeVisible() - await expect(accessRegion.getByRole('button', { name: /^API Key\b/ })).toBeVisible() - await expect(accessRegion.getByRole('link', { name: 'API Reference' })).toBeVisible() - await expect(accessRegion.getByText(/^(?:In|Out of) service$/i)).toHaveCount(2) - await expect(accessRegion.getByRole('heading', { name: 'Workflow access' })).toBeVisible() - await expect(accessRegion.getByRole('columnheader', { name: 'Name' })).toBeVisible() - await expect(accessRegion.getByRole('columnheader', { name: 'Version' })).toBeVisible() - await expect(accessRegion.getByRole('columnheader', { name: 'Nodes' })).toBeVisible() - await expect(accessRegion.getByRole('columnheader', { name: 'Last updated' })).toBeVisible() - await expect(accessRegion.getByRole('columnheader', { name: 'Actions' })).toBeVisible() - await expect(accessRegion.getByText('No workflow references yet.')).toBeVisible() -}) + await expect(webAppCard.getByText('Out of service')).toBeVisible({ timeout: 30_000 }) + await expect(webAppCard.getByLabel('Toggle Web app access')).toBeDisabled() + await expect(webAppCard.getByRole('button', { name: 'Launch' })).toBeDisabled() + await expect(serviceApiCard.getByText('Out of service')).toBeVisible() + await expect(serviceApiCard.getByLabel('Toggle Backend service API access')).toBeDisabled() + await expect(serviceApiCard.getByRole('button', { name: /^API Key\b/ })).toBeDisabled() + }, +) When( /^I disable Agent v2 (Web app|Backend service API) access$/, diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 0191d08ef20..9e1a46e8f95 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -23,6 +23,7 @@ export type AgentAppCreatePayload = { export type AgentAppDetailWithSite = { access_mode?: string | null + access_ready?: boolean api_base_url?: string | null app_id?: string | null backing_app_id?: string | null @@ -78,6 +79,7 @@ export type AgentAppUpdatePayload = { } export type AgentApiAccessResponse = { + access_ready: boolean api_key_count: number api_rph: number api_rpm: number @@ -1896,6 +1898,7 @@ export type AgentAppPaginationWritable = { export type AgentAppDetailWithSiteWritable = { access_mode?: string | null + access_ready?: boolean api_base_url?: string | null app_id?: string | null backing_app_id?: string | null diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index 9a2508dfa1e..f3b35522b5d 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -6,6 +6,7 @@ import * as z from 'zod' * AgentApiAccessResponse */ export const zAgentApiAccessResponse = z.object({ + access_ready: z.boolean(), api_key_count: z.int(), api_rph: z.int(), api_rpm: z.int(), @@ -374,6 +375,7 @@ export const zWorkflowPartial = z.object({ */ export const zAgentAppDetailWithSite = z.object({ access_mode: z.string().nullish(), + access_ready: z.boolean().optional().default(false), api_base_url: z.string().nullish(), app_id: z.string().nullish(), backing_app_id: z.string().nullish(), @@ -2734,6 +2736,7 @@ export const zAppDetailSiteResponseWritable = z.object({ */ export const zAgentAppDetailWithSiteWritable = z.object({ access_mode: z.string().nullish(), + access_ready: z.boolean().optional().default(false), api_base_url: z.string().nullish(), app_id: z.string().nullish(), backing_app_id: z.string().nullish(), diff --git a/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx b/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx index 8f78a4a7631..85e65d69cff 100644 --- a/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx +++ b/web/features/agent-v2/agent-detail/access/components/__tests__/access-surface-cards.spec.tsx @@ -209,6 +209,7 @@ vi.mock('@/service/client', () => ({ function createAgent(overrides: Partial = {}): AgentAppDetailWithSite { return { + access_ready: true, enable_api: true, enable_site: true, icon_url: null, @@ -587,17 +588,35 @@ describe('Agent access surface cards', () => { screen.getByRole('button', { name: 'agentV2.agentDetail.access.webApp.actions.customize' }), ).toBeDisabled() }) + + it('should keep the Web App switch disabled until the Agent is published', () => { + renderWithQueryClient( + , + ) + + expect( + screen.getByRole('switch', { + name: 'agentV2.agentDetail.access.toggleSurface:{"name":"agentV2.agentDetail.access.webApp.title"}', + }), + ).toHaveAttribute('aria-disabled', 'true') + }) }) describe('Service API access', () => { it('should render service API data and toggle Agent API status through the generated Agent endpoint', async () => { const user = userEvent.setup() mocks.apiAccessQueryFn.mockResolvedValueOnce({ + access_ready: true, api_key_count: 2, enabled: true, service_api_base_url: 'https://api.example.test/v1', }) mocks.apiEnableMutation.mockResolvedValueOnce({ + access_ready: true, api_key_count: 2, enabled: false, service_api_base_url: 'https://api.example.test/v1', @@ -629,6 +648,7 @@ describe('Agent access surface cards', () => { it('should manage API keys with the Agent API key endpoints', async () => { const user = userEvent.setup() mocks.apiAccessQueryFn.mockResolvedValue({ + access_ready: true, api_key_count: 1, enabled: true, service_api_base_url: 'https://api.example.test/v1', @@ -691,6 +711,28 @@ describe('Agent access surface cards', () => { }) }) }) + + it('should disable the Service API switch and key action until the Agent is published', async () => { + mocks.apiAccessQueryFn.mockResolvedValueOnce({ + access_ready: false, + api_key_count: 0, + enabled: false, + service_api_base_url: 'https://api.example.test/v1', + }) + + renderWithQueryClient() + + expect( + await screen.findByRole('switch', { + name: 'agentV2.agentDetail.access.toggleSurface:{"name":"agentV2.agentDetail.access.serviceApi.title"}', + }), + ).toHaveAttribute('aria-disabled', 'true') + expect( + screen.getByRole('button', { + name: /agentV2\.agentDetail\.access\.serviceApi\.actions\.apiKey/, + }), + ).toBeDisabled() + }) }) describe('Web app access control', () => { diff --git a/web/features/agent-v2/agent-detail/access/components/service-api-access-card.tsx b/web/features/agent-v2/agent-detail/access/components/service-api-access-card.tsx index 450564f3300..af945ec3ad3 100644 --- a/web/features/agent-v2/agent-detail/access/components/service-api-access-card.tsx +++ b/web/features/agent-v2/agent-detail/access/components/service-api-access-card.tsx @@ -47,6 +47,7 @@ export function ServiceApiAccessCard({ agentId }: { agentId: string }) { }, }), ) + const accessReady = Boolean(apiAccess?.access_ready) const isBusy = apiAccessQuery.isPending || toggleServiceApiMutation.isPending function handleEnabledChange(enabled: boolean) { @@ -71,14 +72,14 @@ export function ServiceApiAccessCard({ agentId }: { agentId: string }) { enabled={Boolean(apiAccess?.enabled)} onEnabledChange={handleEnabledChange} copyLabel={t(($) => $['agentDetail.access.copyServiceEndpoint'])} - disabled={apiAccessQuery.isPending || apiAccessQuery.isError} + disabled={apiAccessQuery.isPending || apiAccessQuery.isError || !accessReady} busy={toggleServiceApiMutation.isPending} >