mirror of
https://github.com/langgenius/dify.git
synced 2026-11-13 06:41:07 +08:00
2158 lines
87 KiB
Python
2158 lines
87 KiB
Python
from collections.abc import Generator
|
|
from datetime import datetime
|
|
from inspect import getsource, unwrap
|
|
from types import SimpleNamespace
|
|
from typing import Any, Self, cast
|
|
from unittest.mock import MagicMock, Mock
|
|
|
|
import pytest
|
|
from flask import Flask
|
|
from sqlalchemy.orm import Session
|
|
from werkzeug.exceptions import Forbidden, InternalServerError, NotFound
|
|
|
|
from controllers.console import console_ns
|
|
from controllers.console.agent import composer as composer_controller
|
|
from controllers.console.agent import roster as roster_controller
|
|
from controllers.console.agent.composer import (
|
|
AgentComposerApi,
|
|
AgentComposerCandidatesApi,
|
|
WorkflowAgentComposerApi,
|
|
WorkflowAgentComposerCandidatesApi,
|
|
WorkflowAgentComposerCopyFromRosterApi,
|
|
WorkflowAgentComposerImpactApi,
|
|
WorkflowAgentComposerSaveToRosterApi,
|
|
WorkflowAgentComposerValidateApi,
|
|
)
|
|
from controllers.console.agent.roster import (
|
|
AgentApiAccessApi,
|
|
AgentApiStatusApi,
|
|
AgentApiStatusPayload,
|
|
AgentAppApi,
|
|
AgentAppCopyApi,
|
|
AgentAppCopyPayload,
|
|
AgentAppCreatePayload,
|
|
AgentAppListApi,
|
|
AgentAppUpdatePayload,
|
|
AgentBuildDraftApi,
|
|
AgentBuildDraftApplyApi,
|
|
AgentBuildDraftCheckoutApi,
|
|
AgentBuildDraftCheckoutPayload,
|
|
AgentDebugConversationRefreshApi,
|
|
AgentInviteOptionsApi,
|
|
AgentInviteOptionsQuery,
|
|
AgentLogMessagesApi,
|
|
AgentLogsApi,
|
|
AgentLogSourcesApi,
|
|
AgentPublishApi,
|
|
AgentPublishPayload,
|
|
AgentRosterVersionDetailApi,
|
|
AgentRosterVersionRestoreApi,
|
|
AgentRosterVersionsApi,
|
|
AgentStatisticsQuery,
|
|
AgentStatisticsSummaryApi,
|
|
)
|
|
from controllers.console.app import completion as completion_controller
|
|
from controllers.console.app.completion import AgentBuildChatFinalizeApi, AgentChatMessageApi, AgentChatMessageStopApi
|
|
from controllers.console.app.error import CompletionRequestError
|
|
from enums import CloudPlan, DeploymentEdition
|
|
from models.account import Account, TenantAccountRole
|
|
from models.agent import Agent, AgentConfigDraftType, AgentScope, AgentSource, AgentStatus
|
|
from models.enums import ApiTokenType, CustomizeTokenStrategy, TagType
|
|
from models.model import ApiToken, App, AppMode, IconType, Site, Tag, TagBinding
|
|
from services.agent.observability_service import AgentLogQueryParams, AgentStatisticsQueryParams
|
|
from services.enterprise.rbac_service import RBACService
|
|
from services.entities.agent_entities import (
|
|
ComposerSavePayload,
|
|
ComposerSaveStrategy,
|
|
ComposerVariant,
|
|
WorkflowAgentComposerQuery,
|
|
WorkflowComposerCopyFromRosterPayload,
|
|
)
|
|
from services.entities.app_entities import AgentAppPublicationCounts, AppListParams, CreateAppParams
|
|
from tests.helpers.unit.config_override import apply_config_overrides
|
|
from tests.helpers.unit.model_factories import make_account
|
|
from tests.helpers.unit.rbac_fakes import RBACDomain
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def rbac_services(monkeypatch: pytest.MonkeyPatch, rbac_domain: RBACDomain) -> None:
|
|
monkeypatch.setattr(roster_controller, "application_services", lambda: rbac_domain)
|
|
|
|
|
|
def _version_response(version_id: str = "version-1") -> dict[str, object]:
|
|
return {
|
|
"id": version_id,
|
|
"agent_id": "agent-1",
|
|
"version": 1,
|
|
"summary": None,
|
|
"version_note": None,
|
|
"created_by": "account-1",
|
|
"created_at": None,
|
|
}
|
|
|
|
|
|
def test_query_values_accepts_repeated_and_indexed_arrays() -> None:
|
|
app = Flask(__name__)
|
|
|
|
with app.test_request_context("/?sources=webapp:app-1&sources%5B1%5D=workflow:app-2&sources%5B0%5D=workflow:app-1"):
|
|
assert roster_controller._query_values("sources", "source") == [
|
|
"webapp:app-1",
|
|
"workflow:app-1",
|
|
"workflow:app-2",
|
|
]
|
|
|
|
with app.test_request_context("/?source%5B0%5D=workflow:app-3"):
|
|
assert roster_controller._query_values("sources", "source") == ["workflow:app-3"]
|
|
|
|
|
|
def _workflow_composer_response(**overrides: object) -> dict[str, object]:
|
|
response: dict[str, object] = {
|
|
"variant": "workflow",
|
|
"agent": None,
|
|
"active_config_snapshot": None,
|
|
"binding": None,
|
|
"soul_lock": {"locked": False, "can_unlock": False, "reason": "workflow_only_empty"},
|
|
"agent_soul": {},
|
|
"node_job": {},
|
|
"effective_declared_outputs": [],
|
|
"save_options": ["node_job_only"],
|
|
"impact_summary": None,
|
|
"app_id": "app-1",
|
|
"workflow_id": "workflow-1",
|
|
"node_id": "node-1",
|
|
}
|
|
response.update(overrides)
|
|
return response
|
|
|
|
|
|
def _agent_app_composer_response() -> dict[str, object]:
|
|
return {
|
|
"variant": "agent_app",
|
|
"agent": {
|
|
"id": "agent-1",
|
|
"name": "Analyst",
|
|
"description": "",
|
|
"scope": "roster",
|
|
"status": "active",
|
|
"active_config_snapshot_id": "version-1",
|
|
},
|
|
"active_config_snapshot": _version_response(),
|
|
"active_config_is_published": True,
|
|
"agent_soul": {},
|
|
"save_options": ["save_to_current_version"],
|
|
}
|
|
|
|
|
|
def _app_detail_obj(**overrides: object) -> App:
|
|
data: dict[str, object] = {
|
|
"id": "app-1",
|
|
"tenant_id": "tenant-1",
|
|
"name": "Iris",
|
|
"description": "Agent app",
|
|
"mode": AppMode.AGENT,
|
|
"icon_type": "emoji",
|
|
"icon": "robot",
|
|
"icon_background": "#fff",
|
|
"enable_site": False,
|
|
"enable_api": False,
|
|
"tracing": None,
|
|
"use_icon_as_answer_icon": False,
|
|
"created_by": "account-1",
|
|
"created_at": datetime(2025, 1, 1),
|
|
"updated_by": "account-1",
|
|
"updated_at": datetime(2025, 1, 1),
|
|
"max_active_requests": 0,
|
|
}
|
|
overrides.pop("bound_agent_id", None)
|
|
data.update(overrides)
|
|
data["icon_type"] = IconType(data["icon_type"])
|
|
app = App()
|
|
for key, value in data.items():
|
|
setattr(app, key, value)
|
|
return app
|
|
|
|
|
|
def _account(*, account_id: str = "account-1", privileged: bool = False, timezone: str | None = None) -> Account:
|
|
return make_account(
|
|
account_id=account_id,
|
|
name="Agent Controller Tester",
|
|
email=f"{account_id}@example.com",
|
|
timezone=timezone,
|
|
role=TenantAccountRole.OWNER if privileged else None,
|
|
)
|
|
|
|
|
|
def _candidates_response(variant: str) -> dict[str, object]:
|
|
return {
|
|
"variant": variant,
|
|
"allowed_node_job_candidates": {},
|
|
"allowed_soul_candidates": {},
|
|
"capabilities": {"human_roster_available": False},
|
|
}
|
|
|
|
|
|
def test_agent_v2_console_routes_are_agent_id_first() -> None:
|
|
paths = {route for item in console_ns.resources for route in item.urls}
|
|
for route in (
|
|
"/agent",
|
|
"/agent/<uuid:agent_id>",
|
|
"/agent/<uuid:agent_id>/composer",
|
|
"/agent/<uuid:agent_id>/composer/validate",
|
|
"/agent/<uuid:agent_id>/composer/candidates",
|
|
"/agent/<uuid:agent_id>/features",
|
|
"/agent/<uuid:agent_id>/copy",
|
|
"/agent/<uuid:agent_id>/publish",
|
|
"/agent/<uuid:agent_id>/build-draft/checkout",
|
|
"/agent/<uuid:agent_id>/build-draft",
|
|
"/agent/<uuid:agent_id>/build-draft/apply",
|
|
"/agent/<uuid:agent_id>/referencing-workflows",
|
|
"/agent/<uuid:agent_id>/sandbox/files",
|
|
"/agent/<uuid:agent_id>/api-access",
|
|
"/agent/<uuid:agent_id>/api-enable",
|
|
"/agent/<uuid:agent_id>/api-keys",
|
|
"/agent/<uuid:agent_id>/api-keys/<uuid:api_key_id>",
|
|
"/agent/<uuid:agent_id>/debug-conversation/refresh",
|
|
"/agent/<uuid:agent_id>/chat-messages",
|
|
"/agent/<uuid:agent_id>/chat-messages/<string:task_id>/stop",
|
|
"/agent/<uuid:agent_id>/feedbacks",
|
|
"/agent/<uuid:agent_id>/chat-messages/<uuid:message_id>/suggested-questions",
|
|
"/agent/<uuid:agent_id>/messages/<uuid:message_id>",
|
|
"/agent/<uuid:agent_id>/logs",
|
|
"/agent/<uuid:agent_id>/logs/<uuid:conversation_id>/messages",
|
|
"/agent/<uuid:agent_id>/log-sources",
|
|
"/agent/<uuid:agent_id>/statistics/summary",
|
|
"/agent/<uuid:agent_id>/versions",
|
|
"/agent/<uuid:agent_id>/versions/<uuid:version_id>",
|
|
"/agent/<uuid:agent_id>/versions/<uuid:version_id>/restore",
|
|
"/agent/invite-options",
|
|
):
|
|
assert route in paths
|
|
for route in (
|
|
"/agents",
|
|
"/agents/invite-options",
|
|
"/agents/<uuid:agent_id>",
|
|
"/agents/<uuid:agent_id>/versions",
|
|
"/apps/<uuid:app_id>/agent-composer",
|
|
"/apps/<uuid:app_id>/agent-composer/validate",
|
|
"/apps/<uuid:app_id>/agent-composer/candidates",
|
|
"/apps/<uuid:app_id>/agent-features",
|
|
"/apps/<uuid:app_id>/agent-referencing-workflows",
|
|
"/apps/<uuid:app_id>/agent-sandbox/files",
|
|
"/apps/<uuid:agent_id>/api-access",
|
|
):
|
|
assert route not in paths
|
|
|
|
|
|
def test_agent_app_write_routes_do_not_reuse_app_billing_quota() -> None:
|
|
for route_class in (AgentAppListApi, AgentAppCopyApi):
|
|
assert '@cloud_edition_billing_resource_check("apps")' not in getsource(route_class)
|
|
|
|
|
|
def test_agent_list_reads_do_not_require_workspace_preview() -> None:
|
|
for route_class in (AgentAppListApi, AgentInviteOptionsApi):
|
|
assert "RBACPermission.AGENT_PREVIEW, Workspace()" not in getsource(route_class.get)
|
|
|
|
|
|
def test_agent_app_permission_keys_are_required_response_fields() -> None:
|
|
assert roster_controller.AgentAppPartial.model_fields["permission_keys"].is_required()
|
|
assert roster_controller.AgentAppDetailWithSite.model_fields["permission_keys"].is_required()
|
|
|
|
|
|
@pytest.fixture
|
|
def account_id() -> str:
|
|
return "account-1"
|
|
|
|
|
|
def test_agent_app_list_and_create_use_agent_route(
|
|
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str, sqlite_session: Session
|
|
) -> None:
|
|
captured: dict[str, object] = {}
|
|
listed_app = _app_detail_obj(id="app-list")
|
|
created_app = _app_detail_obj(id="app-created", enable_site=True)
|
|
tag = Tag(tenant_id="tenant-1", type=TagType.APP, name="Agent tag", created_by=account_id)
|
|
sqlite_session.add_all([listed_app, created_app, tag])
|
|
sqlite_session.flush()
|
|
sqlite_session.add_all(
|
|
[
|
|
Site(
|
|
app_id=created_app.id,
|
|
code="agent-site-code",
|
|
title="Agent web app",
|
|
default_language="en-US",
|
|
customize_token_strategy=CustomizeTokenStrategy.NOT_ALLOW,
|
|
),
|
|
TagBinding(tenant_id="tenant-1", tag_id=tag.id, target_id=listed_app.id, created_by=account_id),
|
|
]
|
|
)
|
|
sqlite_session.flush()
|
|
permissions = roster_controller.rbac_contracts.MyPermissionsResponse(
|
|
agent=roster_controller.rbac_contracts.ResourcePermissionSnapshot(
|
|
overrides=[
|
|
roster_controller.rbac_contracts.ResourcePermissionKeys(
|
|
resource_id="agent-list",
|
|
permission_keys=["agent.acl.preview"],
|
|
)
|
|
]
|
|
)
|
|
)
|
|
|
|
class FakeAgentAccessFilter:
|
|
def apply_to_app_params(self, params: AppListParams, *, tenant_id: str, session: object) -> None:
|
|
captured["access_filter"] = {"tenant_id": tenant_id, "session": session}
|
|
params.accessible_app_ids = ["app-list"]
|
|
|
|
monkeypatch.setattr(
|
|
RBACService.MyPermissions,
|
|
"get",
|
|
lambda *_args, **_kwargs: permissions,
|
|
)
|
|
monkeypatch.setattr(
|
|
roster_controller,
|
|
"resolve_agent_access_filter",
|
|
lambda *_args, **_kwargs: FakeAgentAccessFilter(),
|
|
)
|
|
apply_config_overrides(monkeypatch, RBAC_ENABLED=True)
|
|
|
|
class FakeAppService:
|
|
def get_paginate_apps(
|
|
self, user_id: str, tenant_id: str, params: AppListParams, session: Session
|
|
) -> SimpleNamespace:
|
|
captured["list"] = {"user_id": user_id, "tenant_id": tenant_id, "params": params}
|
|
return SimpleNamespace(
|
|
page=1,
|
|
per_page=10,
|
|
total=1,
|
|
has_next=False,
|
|
items=[listed_app],
|
|
)
|
|
|
|
def get_agent_publication_counts(
|
|
self, user_id: str, tenant_id: str, params: AppListParams, session: Session
|
|
) -> AgentAppPublicationCounts:
|
|
del session
|
|
captured["counts"] = {"user_id": user_id, "tenant_id": tenant_id, "params": params}
|
|
return roster_controller.AgentAppPublicationCounts(published=1, drafts=0)
|
|
|
|
def create_app(self, tenant_id: str, params: CreateAppParams, current_user: object, *, session: object) -> App:
|
|
captured["create"] = {"tenant_id": tenant_id, "params": params, "current_user": current_user}
|
|
return created_app
|
|
|
|
monkeypatch.setattr(roster_controller, "AppService", FakeAppService)
|
|
monkeypatch.setattr(
|
|
roster_controller.AgentRosterService,
|
|
"load_app_backing_agents_by_app_id",
|
|
lambda _self, **kwargs: {
|
|
"app-list": Agent(
|
|
tenant_id="tenant-1",
|
|
name="Agent",
|
|
scope=AgentScope.ROSTER,
|
|
source=AgentSource.AGENT_APP,
|
|
id="agent-list",
|
|
app_id="app-list",
|
|
backing_app_id=None,
|
|
role="List role",
|
|
active_config_snapshot_id=None,
|
|
)
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
roster_controller.AgentRosterService,
|
|
"get_app_backing_agent",
|
|
lambda _self, **kwargs: Agent(
|
|
tenant_id="tenant-1",
|
|
name="Agent",
|
|
scope=AgentScope.ROSTER,
|
|
source=AgentSource.AGENT_APP,
|
|
id="agent-created",
|
|
app_id="app-created",
|
|
backing_app_id=None,
|
|
role="Created role",
|
|
active_config_snapshot_id=None,
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
roster_controller.AgentRosterService,
|
|
"get_or_create_build_conversation",
|
|
lambda _self, **kwargs: "debug-conversation-detail",
|
|
)
|
|
monkeypatch.setattr(
|
|
roster_controller.AgentRosterService,
|
|
"load_published_references_by_agent_id",
|
|
lambda _self, **kwargs: {
|
|
"agent-list": [
|
|
{
|
|
"app_id": "workflow-app-id",
|
|
"app_name": "RFP Review Flow",
|
|
"app_icon_type": "emoji",
|
|
"app_icon": "A",
|
|
"app_icon_background": "#fff",
|
|
"app_mode": "workflow",
|
|
"app_updated_at": 1781660000,
|
|
"workflow_id": "workflow-1",
|
|
"workflow_version": "v1",
|
|
"node_ids": ["node-1", "node-2"],
|
|
}
|
|
]
|
|
},
|
|
)
|
|
monkeypatch.setattr(
|
|
roster_controller.AgentRosterService,
|
|
"load_reference_counts_by_agent_id",
|
|
lambda _self, **kwargs: {"agent-list": 2},
|
|
)
|
|
monkeypatch.setattr(
|
|
roster_controller.AgentRosterService,
|
|
"load_or_create_build_conversation_ids_by_agent_id",
|
|
lambda _self, **kwargs: {"agent-list": "debug-conversation-list"},
|
|
)
|
|
monkeypatch.setattr(
|
|
roster_controller.AgentRosterService, "count_agent_app_debug_conversation_messages", lambda _self, **kwargs: 0
|
|
)
|
|
|
|
def get_or_create_debug_conversation(_self: object, **kwargs: object) -> str:
|
|
captured["get_or_create_debug_conversation"] = kwargs
|
|
return "debug-conversation-created"
|
|
|
|
monkeypatch.setattr(
|
|
roster_controller.AgentRosterService,
|
|
"get_or_create_build_conversation",
|
|
get_or_create_debug_conversation,
|
|
)
|
|
monkeypatch.setattr(
|
|
roster_controller.SystemFeatureService,
|
|
"is_webapp_auth_enabled",
|
|
lambda: False,
|
|
)
|
|
with app.test_request_context(
|
|
"/console/api/agent?page=1&limit=10&mode=workflow&sort_by=recently_created"
|
|
"&is_created_by_me=true&publication_status=published"
|
|
):
|
|
listed = unwrap(AgentAppListApi.get)(
|
|
AgentAppListApi(), sqlite_session, "tenant-1", _account(account_id=account_id)
|
|
)
|
|
assert listed["page"] == 1
|
|
assert listed["limit"] == 10
|
|
assert listed["total"] == 1
|
|
assert listed["publication_counts"] == {"published": 1, "drafts": 0}
|
|
assert listed["data"][0]["id"] == "agent-list"
|
|
assert listed["data"][0]["app_id"] == "app-list"
|
|
assert listed["data"][0]["debug_conversation_id"] == "debug-conversation-list"
|
|
assert listed["data"][0]["permission_keys"] == ["agent.acl.preview"]
|
|
assert listed["data"][0]["role"] == "List role"
|
|
assert listed["data"][0]["tags"] == [{"id": tag.id, "name": "Agent tag", "type": "app"}]
|
|
assert listed["data"][0]["active_config_is_published"] is False
|
|
assert listed["data"][0]["reference_count"] == 2
|
|
assert listed["data"][0]["published_reference_count"] == 1
|
|
assert listed["data"][0]["published_references"] == [
|
|
{
|
|
"app_id": "workflow-app-id",
|
|
"app_name": "RFP Review Flow",
|
|
"app_icon_type": "emoji",
|
|
"app_icon": "A",
|
|
"app_icon_background": "#fff",
|
|
}
|
|
]
|
|
assert "bound_agent_id" not in listed["data"][0]
|
|
list_call = cast(dict[str, object], captured["list"])
|
|
list_params = cast(Any, list_call["params"])
|
|
assert list_params.mode == "agent"
|
|
assert list_params.sort_by == "recently_created"
|
|
assert list_params.is_created_by_me is True
|
|
assert list_params.agent_is_published is True
|
|
assert list_params.status == "normal"
|
|
assert list_params.accessible_app_ids == ["app-list"]
|
|
assert captured["access_filter"] == {"tenant_id": "tenant-1", "session": sqlite_session}
|
|
count_call = cast(dict[str, object], captured["counts"])
|
|
count_params = cast(Any, count_call["params"])
|
|
assert count_params.agent_is_published is True
|
|
with app.test_request_context(
|
|
"/console/api/agent",
|
|
json={"name": "Iris", "description": "Agent app", "role": "Coordinator", "icon_type": "emoji", "icon": "robot"},
|
|
):
|
|
created, status = unwrap(AgentAppListApi.post)(
|
|
AgentAppListApi(),
|
|
AgentAppCreatePayload(
|
|
name="Iris", description="Agent app", role="Coordinator", icon_type="emoji", icon="robot"
|
|
),
|
|
sqlite_session,
|
|
"tenant-1",
|
|
_account(account_id=account_id),
|
|
)
|
|
assert status == 201
|
|
assert created["id"] == "agent-created"
|
|
assert created["app_id"] == "app-created"
|
|
assert created["debug_conversation_id"] == "debug-conversation-created"
|
|
assert created["role"] == "Created role"
|
|
assert created["enable_site"] is True
|
|
assert created["site"]["code"] == "agent-site-code"
|
|
assert created["site"]["access_token"] == "agent-site-code"
|
|
assert created["site"]["title"] == "Agent web app"
|
|
assert "active_config_is_published" not in created
|
|
assert "bound_agent_id" not in created
|
|
create_call = cast(dict[str, object], captured["create"])
|
|
create_params = cast(Any, create_call["params"])
|
|
assert create_params.mode == "agent"
|
|
assert create_params.agent_role == "Coordinator"
|
|
assert captured["get_or_create_debug_conversation"] == {
|
|
"tenant_id": "tenant-1",
|
|
"agent_id": "agent-created",
|
|
"account_id": account_id,
|
|
"commit": False,
|
|
}
|
|
|
|
|
|
def test_agent_app_create_payload_allows_optional_role() -> None:
|
|
omitted = roster_controller.AgentAppCreatePayload.model_validate(
|
|
{"name": "Iris", "description": "Agent app", "icon_type": "emoji", "icon": "robot"}
|
|
)
|
|
blank = roster_controller.AgentAppCreatePayload.model_validate(
|
|
{"name": "Iris", "description": "Agent app", "role": " ", "icon_type": "emoji", "icon": "robot"}
|
|
)
|
|
assert omitted.role is None
|
|
assert blank.role == ""
|
|
|
|
|
|
def test_agent_app_create_omits_optional_role_as_empty_string(
|
|
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
|
|
) -> None:
|
|
captured: dict[str, object] = {}
|
|
|
|
class FakeAppService:
|
|
def create_app(self, tenant_id: str, params: object, account: object, *, session: object) -> object:
|
|
del session
|
|
captured["create"] = {"tenant_id": tenant_id, "params": params, "account": account}
|
|
return _app_detail_obj(id="app-created", bound_agent_id="agent-created")
|
|
|
|
monkeypatch.setattr(roster_controller, "AppService", FakeAppService)
|
|
monkeypatch.setattr(
|
|
roster_controller,
|
|
"_serialize_agent_app_detail",
|
|
lambda _session, app_model, **_kwargs: {"id": "agent-created", "app_id": app_model.id},
|
|
)
|
|
current_user = _account(account_id=account_id)
|
|
with app.test_request_context(
|
|
"/console/api/agent",
|
|
json={"name": "No-role Iris", "description": "Agent app", "icon_type": "emoji", "icon": "robot"},
|
|
):
|
|
created, status = unwrap(AgentAppListApi.post)(
|
|
AgentAppListApi(),
|
|
AgentAppCreatePayload(name="No-role Iris", description="Agent app", icon_type="emoji", icon="robot"),
|
|
MagicMock(),
|
|
"tenant-1",
|
|
current_user,
|
|
)
|
|
assert status == 201
|
|
assert created == {"id": "agent-created", "app_id": "app-created"}
|
|
create_call = cast(dict[str, object], captured["create"])
|
|
create_params = cast(Any, create_call["params"])
|
|
assert create_call["tenant_id"] == "tenant-1"
|
|
assert create_call["account"] is current_user
|
|
assert create_params.agent_role == ""
|
|
|
|
|
|
def test_agent_app_detail_update_delete_resolve_app_from_agent_id(
|
|
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str, sqlite_session: Session
|
|
) -> None:
|
|
agent_id = "00000000-0000-0000-0000-000000000001"
|
|
tenant_id = "00000000-0000-0000-0000-000000000002"
|
|
app_id = "00000000-0000-0000-0000-000000000003"
|
|
app_model = _app_detail_obj(id=app_id, tenant_id=tenant_id, bound_agent_id=agent_id)
|
|
agent = Agent(
|
|
tenant_id=tenant_id,
|
|
name="Resolved agent",
|
|
description="",
|
|
role="Resolved role",
|
|
scope=AgentScope.ROSTER,
|
|
source=AgentSource.AGENT_APP,
|
|
app_id=app_id,
|
|
status=AgentStatus.ACTIVE,
|
|
)
|
|
agent.id = agent_id
|
|
sqlite_session.add(agent)
|
|
sqlite_session.flush()
|
|
captured: dict[str, object] = {}
|
|
monkeypatch.setattr(roster_controller.AgentRosterService, "get_agent_app_model", lambda _self, **kwargs: app_model)
|
|
monkeypatch.setattr(roster_controller, "_resolve_agent_runtime_app_model", lambda _session, **kwargs: app_model)
|
|
monkeypatch.setattr(roster_controller.AgentRosterService, "get_app_backing_agent", lambda _self, **kwargs: agent)
|
|
monkeypatch.setattr(
|
|
roster_controller.AgentRosterService,
|
|
"get_or_create_build_conversation",
|
|
lambda _self, **kwargs: "debug-conversation-detail",
|
|
)
|
|
monkeypatch.setattr(
|
|
roster_controller.AgentRosterService, "count_agent_app_debug_conversation_messages", lambda _self, **kwargs: 2
|
|
)
|
|
monkeypatch.setattr(
|
|
roster_controller.SystemFeatureService,
|
|
"is_webapp_auth_enabled",
|
|
lambda: False,
|
|
)
|
|
monkeypatch.setattr(
|
|
roster_controller,
|
|
"agent_has_workflow_callable_active_snapshot",
|
|
lambda **_kwargs: False,
|
|
)
|
|
apply_config_overrides(monkeypatch, RBAC_ENABLED=True)
|
|
monkeypatch.setattr(
|
|
RBACService.MyPermissions,
|
|
"get",
|
|
lambda *_args, **_kwargs: roster_controller.rbac_contracts.MyPermissionsResponse(
|
|
agent=roster_controller.rbac_contracts.ResourcePermissionSnapshot(
|
|
default_permission_keys=["agent.acl.preview"]
|
|
)
|
|
),
|
|
)
|
|
|
|
class FakeAppService:
|
|
def update_app(self, app_obj: object, args: dict[str, object], *, session: object) -> object:
|
|
captured["update"] = {"app": app_obj, "args": args}
|
|
return _app_detail_obj(id=app_id, tenant_id=tenant_id, name=args["name"], bound_agent_id=agent_id)
|
|
|
|
def delete_app(self, app_obj: object, *, session: object) -> None:
|
|
captured["delete"] = app_obj
|
|
|
|
monkeypatch.setattr(roster_controller, "AppService", FakeAppService)
|
|
session = sqlite_session
|
|
with app.test_request_context():
|
|
detail = unwrap(AgentAppApi.get)(AgentAppApi(), session, tenant_id, _account(account_id=account_id), agent_id)
|
|
assert detail["id"] == agent_id
|
|
assert detail["app_id"] == app_id
|
|
assert detail["debug_conversation_id"] == "debug-conversation-detail"
|
|
assert detail["debug_conversation_has_messages"] is True
|
|
assert detail["debug_conversation_message_count"] == 2
|
|
assert detail["role"] == "Resolved role"
|
|
assert detail["permission_keys"] == ["agent.acl.preview"]
|
|
assert detail["access_ready"] is False
|
|
assert "active_config_is_published" not in detail
|
|
assert "bound_agent_id" not in detail
|
|
with app.test_request_context(
|
|
"/console/api/agent/00000000-0000-0000-0000-000000000001",
|
|
json={"name": "Renamed", "description": "", "role": "Reviewer", "icon_type": "emoji", "icon": "R"},
|
|
):
|
|
updated = unwrap(AgentAppApi.put)(
|
|
AgentAppApi(),
|
|
AgentAppUpdatePayload(name="Renamed", description="", role="Reviewer", icon_type="emoji", icon="R"),
|
|
session,
|
|
tenant_id,
|
|
_account(account_id=account_id),
|
|
agent_id,
|
|
)
|
|
assert updated["name"] == "Renamed"
|
|
assert updated["id"] == agent_id
|
|
assert updated["app_id"] == app_id
|
|
assert updated["debug_conversation_id"] == "debug-conversation-detail"
|
|
assert updated["debug_conversation_has_messages"] is True
|
|
assert updated["debug_conversation_message_count"] == 2
|
|
assert updated["role"] == "Resolved role"
|
|
assert "active_config_is_published" not in updated
|
|
assert "bound_agent_id" not in updated
|
|
update_call = cast(dict[str, object], captured["update"])
|
|
assert update_call["app"] is app_model
|
|
assert cast(dict[str, object], update_call["args"])["role"] == "Reviewer"
|
|
deleted, status = unwrap(AgentAppApi.delete)(AgentAppApi(), session, tenant_id, agent_id)
|
|
assert (deleted, status) == ("", 204)
|
|
assert captured["delete"] is app_model
|
|
|
|
|
|
def test_agent_app_copy_uses_agent_id_and_returns_agent_detail(
|
|
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
|
|
) -> None:
|
|
agent_id = "00000000-0000-0000-0000-000000000001"
|
|
current_user = _account(account_id=account_id)
|
|
copied_app = _app_detail_obj(id="copied-app", bound_agent_id="copied-agent")
|
|
captured: dict[str, object] = {}
|
|
|
|
class FakeRosterService:
|
|
def duplicate_agent_app(self, **kwargs: object) -> object:
|
|
captured.update(kwargs)
|
|
return copied_app
|
|
|
|
monkeypatch.setattr(roster_controller, "_agent_roster_service", lambda *_args: FakeRosterService())
|
|
monkeypatch.setattr(
|
|
roster_controller,
|
|
"_serialize_agent_app_detail",
|
|
lambda _session, app_model, **_kwargs: {"id": "copied-agent", "app_id": app_model.id, "name": app_model.name},
|
|
)
|
|
with app.test_request_context(
|
|
"/console/api/agent/00000000-0000-0000-0000-000000000001/copy",
|
|
json={
|
|
"name": "Iris copy",
|
|
"description": "Copied",
|
|
"role": "Copied role",
|
|
"icon_type": "emoji",
|
|
"icon": "sparkles",
|
|
"icon_background": "#fff",
|
|
},
|
|
):
|
|
copied, status = unwrap(AgentAppCopyApi.post)(
|
|
AgentAppCopyApi(),
|
|
AgentAppCopyPayload(
|
|
name="Iris copy",
|
|
description="Copied",
|
|
role="Copied role",
|
|
icon_type="emoji",
|
|
icon="sparkles",
|
|
icon_background="#fff",
|
|
),
|
|
MagicMock(),
|
|
"tenant-1",
|
|
current_user,
|
|
agent_id,
|
|
)
|
|
assert status == 201
|
|
assert copied == {"id": "copied-agent", "app_id": "copied-app", "name": "Iris"}
|
|
assert captured == {
|
|
"tenant_id": "tenant-1",
|
|
"agent_id": agent_id,
|
|
"account": current_user,
|
|
"name": "Iris copy",
|
|
"description": "Copied",
|
|
"role": "Copied role",
|
|
"icon_type": "emoji",
|
|
"icon": "sparkles",
|
|
"icon_background": "#fff",
|
|
}
|
|
|
|
|
|
def test_agent_debug_conversation_refresh_resets_build_for_current_user(
|
|
app: Flask,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
account_id: str,
|
|
) -> None:
|
|
agent_id = "00000000-0000-0000-0000-000000000001"
|
|
captured: dict[str, object] = {}
|
|
|
|
class FakeRosterService:
|
|
def reset_build_conversation(self, **kwargs: object) -> str:
|
|
captured.update(kwargs)
|
|
return "new-debug-conversation-id"
|
|
|
|
monkeypatch.setattr(roster_controller, "_agent_roster_service", lambda *_args: FakeRosterService())
|
|
with app.test_request_context(
|
|
"/console/api/agent/00000000-0000-0000-0000-000000000001/debug-conversation/refresh",
|
|
method="POST",
|
|
):
|
|
response = unwrap(AgentDebugConversationRefreshApi.post)(
|
|
AgentDebugConversationRefreshApi(), MagicMock(), "tenant-1", _account(account_id=account_id), agent_id
|
|
)
|
|
assert response == {
|
|
"debug_conversation_id": "new-debug-conversation-id",
|
|
"debug_conversation_has_messages": False,
|
|
"debug_conversation_message_count": 0,
|
|
}
|
|
assert captured == {
|
|
"tenant_id": "tenant-1",
|
|
"agent_id": agent_id,
|
|
"account_id": account_id,
|
|
}
|
|
|
|
|
|
def test_agent_publish_and_build_draft_routes_call_composer_service(
|
|
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
|
|
) -> None:
|
|
agent_id = "00000000-0000-0000-0000-000000000001"
|
|
current_user = _account(account_id=account_id)
|
|
captured: dict[str, dict[str, object]] = {}
|
|
|
|
def publish_agent_app_draft(**kwargs: object) -> dict[str, object]:
|
|
captured["publish"] = kwargs
|
|
return {"result": "success", "active_config_snapshot_id": "version-1"}
|
|
|
|
def checkout_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
|
|
captured["checkout"] = kwargs
|
|
return {"variant": "agent_app", "draft": {"id": "build-draft-1"}, "agent_soul": {}}
|
|
|
|
def load_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
|
|
captured["load"] = kwargs
|
|
return {"variant": "agent_app", "draft": {"id": "build-draft-1"}, "agent_soul": {}}
|
|
|
|
def save_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
|
|
captured["save"] = kwargs
|
|
return {"variant": "agent_app", "draft": {"id": "build-draft-1"}, "agent_soul": {}}
|
|
|
|
def apply_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
|
|
captured["apply"] = kwargs
|
|
return {"result": "success", "draft": {"id": "draft-1"}}
|
|
|
|
def discard_agent_app_build_draft(**kwargs: object) -> dict[str, object]:
|
|
captured["discard"] = kwargs
|
|
return {"result": "success"}
|
|
|
|
monkeypatch.setattr(roster_controller.AgentComposerService, "publish_agent_app_draft", publish_agent_app_draft)
|
|
monkeypatch.setattr(
|
|
roster_controller.AgentComposerService, "checkout_agent_app_build_draft", checkout_agent_app_build_draft
|
|
)
|
|
monkeypatch.setattr(
|
|
roster_controller.AgentComposerService, "load_agent_app_build_draft", load_agent_app_build_draft
|
|
)
|
|
monkeypatch.setattr(
|
|
roster_controller.AgentComposerService, "save_agent_app_build_draft", save_agent_app_build_draft
|
|
)
|
|
monkeypatch.setattr(
|
|
roster_controller.AgentComposerService, "apply_agent_app_build_draft", apply_agent_app_build_draft
|
|
)
|
|
monkeypatch.setattr(
|
|
roster_controller.AgentComposerService, "discard_agent_app_build_draft", discard_agent_app_build_draft
|
|
)
|
|
with app.test_request_context(
|
|
"/console/api/agent/00000000-0000-0000-0000-000000000001/publish", json={"version_note": "publish v1"}
|
|
):
|
|
published = unwrap(AgentPublishApi.post)(
|
|
AgentPublishApi(),
|
|
AgentPublishPayload(version_note="publish v1"),
|
|
MagicMock(),
|
|
"tenant-1",
|
|
current_user,
|
|
agent_id,
|
|
)
|
|
assert published["active_config_snapshot_id"] == "version-1"
|
|
captured["publish"].pop("session", None)
|
|
assert captured["publish"] == {
|
|
"tenant_id": "tenant-1",
|
|
"agent_id": agent_id,
|
|
"account_id": account_id,
|
|
"version_note": "publish v1",
|
|
}
|
|
with app.test_request_context(
|
|
"/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft/checkout", json={"force": True}
|
|
):
|
|
checked_out = unwrap(AgentBuildDraftCheckoutApi.post)(
|
|
AgentBuildDraftCheckoutApi(),
|
|
AgentBuildDraftCheckoutPayload(force=True),
|
|
MagicMock(),
|
|
"tenant-1",
|
|
current_user,
|
|
agent_id,
|
|
)
|
|
assert checked_out["draft"]["id"] == "build-draft-1"
|
|
captured["checkout"].pop("session", None)
|
|
assert captured["checkout"] == {
|
|
"tenant_id": "tenant-1",
|
|
"agent_id": agent_id,
|
|
"account_id": account_id,
|
|
"force": True,
|
|
}
|
|
with app.test_request_context("/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft"):
|
|
loaded = unwrap(AgentBuildDraftApi.get)(AgentBuildDraftApi(), MagicMock(), "tenant-1", current_user, agent_id)
|
|
assert loaded["draft"]["id"] == "build-draft-1"
|
|
captured["load"].pop("session", None)
|
|
assert captured["load"] == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id}
|
|
with app.test_request_context(
|
|
"/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft",
|
|
json={"variant": "agent_app", "save_strategy": "save_to_current_version", "agent_soul": {}},
|
|
):
|
|
saved = unwrap(AgentBuildDraftApi.put)(
|
|
AgentBuildDraftApi(),
|
|
ComposerSavePayload(
|
|
variant=ComposerVariant.AGENT_APP,
|
|
save_strategy=ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION,
|
|
),
|
|
MagicMock(),
|
|
"tenant-1",
|
|
current_user,
|
|
agent_id,
|
|
)
|
|
assert saved["draft"]["id"] == "build-draft-1"
|
|
assert captured["save"]["tenant_id"] == "tenant-1"
|
|
assert captured["save"]["agent_id"] == agent_id
|
|
assert captured["save"]["account_id"] == account_id
|
|
assert cast(Any, captured["save"]["payload"]).variant == ComposerVariant.AGENT_APP
|
|
with app.test_request_context(
|
|
"/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft/apply", method="POST"
|
|
):
|
|
applied = unwrap(AgentBuildDraftApplyApi.post)(
|
|
AgentBuildDraftApplyApi(), MagicMock(), "tenant-1", current_user, agent_id
|
|
)
|
|
assert applied == {"result": "success", "draft": {"id": "draft-1"}}
|
|
captured["apply"].pop("session", None)
|
|
assert captured["apply"] == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id}
|
|
with app.test_request_context(
|
|
"/console/api/agent/00000000-0000-0000-0000-000000000001/build-draft", method="DELETE"
|
|
):
|
|
discarded = unwrap(AgentBuildDraftApi.delete)(
|
|
AgentBuildDraftApi(), MagicMock(), "tenant-1", current_user, agent_id
|
|
)
|
|
assert discarded == {"result": "success"}
|
|
captured["discard"].pop("session", None)
|
|
assert captured["discard"] == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id}
|
|
|
|
|
|
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 = _app_detail_obj(
|
|
id="app-1",
|
|
tenant_id="tenant-1",
|
|
enable_api=True,
|
|
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: 2)
|
|
monkeypatch.setattr(roster_controller, "_agent_app_access_ready", lambda _session, _app: True)
|
|
apply_config_overrides(monkeypatch, SERVICE_API_URL="https://api.example.test/v1")
|
|
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,
|
|
"chat_endpoint": "https://api.example.test/v1/chat-messages",
|
|
"stop_endpoint": "https://api.example.test/v1/chat-messages/{task_id}/stop",
|
|
"conversations_endpoint": "https://api.example.test/v1/conversations",
|
|
"messages_endpoint": "https://api.example.test/v1/messages",
|
|
"files_upload_endpoint": "https://api.example.test/v1/files/upload",
|
|
"parameters_endpoint": "https://api.example.test/v1/parameters",
|
|
"info_endpoint": "https://api.example.test/v1/info",
|
|
"meta_endpoint": "https://api.example.test/v1/meta",
|
|
"api_rpm": 60,
|
|
"api_rph": 600,
|
|
"api_key_count": 2,
|
|
}
|
|
|
|
|
|
def test_agent_api_key_count_scopes_tenant_and_keeps_legacy_tokens(sqlite_session: Session) -> None:
|
|
app_model = _app_detail_obj()
|
|
sqlite_session.add_all(
|
|
[
|
|
ApiToken(type=ApiTokenType.APP, token="owned", app_id=app_model.id, tenant_id=app_model.tenant_id),
|
|
ApiToken(type=ApiTokenType.APP, token="legacy", app_id=app_model.id, tenant_id=None),
|
|
ApiToken(type=ApiTokenType.APP, token="foreign", app_id=app_model.id, tenant_id="tenant-2"),
|
|
]
|
|
)
|
|
sqlite_session.commit()
|
|
|
|
assert roster_controller._agent_api_key_count(sqlite_session, app_model) == 2
|
|
|
|
|
|
def test_agent_api_status_resolves_backing_app(
|
|
app: Flask, monkeypatch: pytest.MonkeyPatch, unbound_session: Session
|
|
) -> None:
|
|
agent_id = "00000000-0000-0000-0000-000000000001"
|
|
app_model = _app_detail_obj(
|
|
id="app-1",
|
|
tenant_id="tenant-1",
|
|
enable_api=False,
|
|
api_rpm=0,
|
|
api_rph=0,
|
|
)
|
|
captured: dict[str, object] = {}
|
|
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: 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:
|
|
captured["enable"] = {"app": app_obj, "enable_api": enable_api}
|
|
app_model.enable_api = enable_api
|
|
return app_model
|
|
|
|
monkeypatch.setattr(roster_controller, "AppService", FakeAppService)
|
|
|
|
with app.test_request_context(
|
|
"/console/api/agent/00000000-0000-0000-0000-000000000001/api-enable", json={"enable_api": True}
|
|
):
|
|
enabled = unwrap(AgentApiStatusApi.post)(
|
|
AgentApiStatusApi(), AgentApiStatusPayload(enable_api=True), unbound_session, "tenant-1", agent_id
|
|
)
|
|
assert enabled["enabled"] is True
|
|
assert captured["enable"] == {"app": app_model, "enable_api": True}
|
|
resolve_app.assert_called_once_with(unbound_session, tenant_id="tenant-1", agent_id=agent_id)
|
|
|
|
|
|
def test_agent_app_update_allows_empty_role(
|
|
app: Flask, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session
|
|
) -> None:
|
|
agent_id = "00000000-0000-0000-0000-000000000001"
|
|
app_model = _app_detail_obj(id="app-1", bound_agent_id=agent_id)
|
|
captured: dict[str, object] = {}
|
|
monkeypatch.setattr(roster_controller.AgentRosterService, "get_agent_app_model", lambda _self, **kwargs: app_model)
|
|
monkeypatch.setattr(
|
|
roster_controller.AgentRosterService,
|
|
"get_app_backing_agent",
|
|
lambda _self, **kwargs: Agent(
|
|
tenant_id="tenant-1",
|
|
name="Agent",
|
|
scope=AgentScope.ROSTER,
|
|
source=AgentSource.AGENT_APP,
|
|
id=agent_id,
|
|
app_id="app-1",
|
|
backing_app_id=None,
|
|
role="",
|
|
active_config_snapshot_id=None,
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
roster_controller.AgentRosterService,
|
|
"get_or_create_build_conversation",
|
|
lambda _self, **kwargs: "debug-conversation-detail",
|
|
)
|
|
monkeypatch.setattr(
|
|
roster_controller.AgentRosterService, "count_agent_app_debug_conversation_messages", lambda _self, **kwargs: 0
|
|
)
|
|
monkeypatch.setattr(
|
|
roster_controller.SystemFeatureService,
|
|
"is_webapp_auth_enabled",
|
|
lambda: False,
|
|
)
|
|
|
|
class FakeAppService:
|
|
def update_app(self, app_obj: object, args: dict[str, object], *, session: object) -> object:
|
|
captured["update"] = {"app": app_obj, "args": args}
|
|
return _app_detail_obj(id="app-1", name=args["name"], bound_agent_id=agent_id)
|
|
|
|
monkeypatch.setattr(roster_controller, "AppService", FakeAppService)
|
|
with app.test_request_context(
|
|
"/console/api/agent/00000000-0000-0000-0000-000000000001",
|
|
json={"name": "Renamed", "description": "", "role": "", "icon_type": "emoji", "icon": "R"},
|
|
):
|
|
updated = unwrap(AgentAppApi.put)(
|
|
AgentAppApi(),
|
|
AgentAppUpdatePayload(name="Renamed", description="", role="", icon_type="emoji", icon="R"),
|
|
sqlite_session,
|
|
"tenant-1",
|
|
_account(),
|
|
agent_id,
|
|
)
|
|
assert updated["role"] == ""
|
|
update_call = cast(dict[str, object], captured["update"])
|
|
assert cast(dict[str, object], update_call["args"])["role"] == ""
|
|
|
|
|
|
def test_invite_options_get_parses_app_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
captured: dict[str, object] = {}
|
|
|
|
def list_invite_options(_self: object, **kwargs: object) -> dict[str, object]:
|
|
captured.update(kwargs)
|
|
return {"data": [], "page": kwargs["page"], "limit": kwargs["limit"], "total": 0, "has_more": False}
|
|
|
|
monkeypatch.setattr(roster_controller.AgentRosterService, "list_invite_options", list_invite_options)
|
|
with app.test_request_context("/console/api/agent/invite-options?page=1&limit=10&app_id=app-1"):
|
|
result = unwrap(AgentInviteOptionsApi.get)(
|
|
AgentInviteOptionsApi(),
|
|
AgentInviteOptionsQuery(page=1, limit=10, app_id="app-1"),
|
|
MagicMock(),
|
|
"tenant-1",
|
|
_account(),
|
|
)
|
|
assert result == {"data": [], "page": 1, "limit": 10, "total": 0, "has_more": False}
|
|
assert captured == {
|
|
"tenant_id": "tenant-1",
|
|
"page": 1,
|
|
"limit": 10,
|
|
"keyword": None,
|
|
"app_id": "app-1",
|
|
"accessible_agent_ids": None,
|
|
}
|
|
|
|
|
|
def test_invite_options_get_applies_resource_visibility(
|
|
app: Flask,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
captured: dict[str, object] = {}
|
|
permissions = roster_controller.rbac_contracts.MyPermissionsResponse()
|
|
|
|
monkeypatch.setattr(
|
|
RBACService.MyPermissions,
|
|
"get",
|
|
lambda *_args, **_kwargs: permissions,
|
|
)
|
|
monkeypatch.setattr(
|
|
roster_controller,
|
|
"resolve_agent_access_filter",
|
|
lambda *_args, **_kwargs: SimpleNamespace(accessible_agent_ids={"agent-2", "agent-1"}),
|
|
)
|
|
monkeypatch.setattr(
|
|
roster_controller.AgentRosterService,
|
|
"list_invite_options",
|
|
lambda _self, **kwargs: (
|
|
captured.update(kwargs) or {"data": [], "page": 1, "limit": 10, "total": 0, "has_more": False}
|
|
),
|
|
)
|
|
apply_config_overrides(monkeypatch, RBAC_ENABLED=True)
|
|
|
|
with app.test_request_context("/console/api/agent/invite-options?page=1&limit=10"):
|
|
unwrap(AgentInviteOptionsApi.get)(
|
|
AgentInviteOptionsApi(),
|
|
AgentInviteOptionsQuery(page=1, limit=10),
|
|
MagicMock(),
|
|
"tenant-1",
|
|
_account(),
|
|
)
|
|
|
|
assert captured["accessible_agent_ids"] == ["agent-1", "agent-2"]
|
|
|
|
|
|
def test_agent_version_queries_do_not_require_paid_plan(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
agent_id = "00000000-0000-0000-0000-000000000001"
|
|
version_id = "00000000-0000-0000-0000-000000000002"
|
|
apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=DeploymentEdition.CLOUD)
|
|
get_plan = Mock(return_value=CloudPlan.SANDBOX)
|
|
monkeypatch.setattr(roster_controller.FeatureService, "get_workspace_plan", get_plan)
|
|
monkeypatch.setattr(
|
|
roster_controller.AgentRosterService, "list_agent_versions", lambda _self, **kwargs: [_version_response()]
|
|
)
|
|
monkeypatch.setattr(
|
|
roster_controller.AgentRosterService,
|
|
"get_agent_version_detail",
|
|
lambda _self, **kwargs: {
|
|
**_version_response(cast(str, kwargs["version_id"])),
|
|
"agent_id": kwargs["agent_id"],
|
|
"config_snapshot": {},
|
|
"revisions": [
|
|
{
|
|
"id": "revision-1",
|
|
"previous_snapshot_id": None,
|
|
"current_snapshot_id": kwargs["version_id"],
|
|
"revision": 1,
|
|
"operation": "create_version",
|
|
"summary": None,
|
|
"version_note": None,
|
|
"created_by": "account-1",
|
|
"created_at": None,
|
|
}
|
|
],
|
|
},
|
|
)
|
|
assert (
|
|
unwrap(AgentRosterVersionsApi.get)(AgentRosterVersionsApi(), MagicMock(), "tenant-1", agent_id)["data"][0]["id"]
|
|
== "version-1"
|
|
)
|
|
version_detail = unwrap(AgentRosterVersionDetailApi.get)(
|
|
AgentRosterVersionDetailApi(), MagicMock(), "tenant-1", agent_id, version_id
|
|
)
|
|
assert version_detail["id"] == version_id
|
|
assert version_detail["agent_id"] == agent_id
|
|
get_plan.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("edition", "plan", "allowed"),
|
|
[
|
|
(DeploymentEdition.CLOUD, CloudPlan.SANDBOX, False),
|
|
(DeploymentEdition.CLOUD, CloudPlan.PROFESSIONAL, True),
|
|
(DeploymentEdition.CLOUD, CloudPlan.TEAM, True),
|
|
(DeploymentEdition.COMMUNITY, CloudPlan.SANDBOX, True),
|
|
(DeploymentEdition.ENTERPRISE, CloudPlan.SANDBOX, True),
|
|
],
|
|
)
|
|
def test_agent_version_restore_requires_cloud_paid_plan(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
sqlite_session: Session,
|
|
edition: DeploymentEdition,
|
|
plan: CloudPlan,
|
|
allowed: bool,
|
|
) -> None:
|
|
agent_id = "00000000-0000-0000-0000-000000000001"
|
|
version_id = "00000000-0000-0000-0000-000000000002"
|
|
apply_config_overrides(monkeypatch, DEPLOYMENT_EDITION=edition)
|
|
get_plan = Mock(return_value=plan)
|
|
monkeypatch.setattr(roster_controller.FeatureService, "get_workspace_plan", get_plan)
|
|
restore = Mock(return_value={"result": "success", "active_config_snapshot_id": version_id})
|
|
monkeypatch.setattr(roster_controller.AgentRosterService, "restore_agent_version", restore)
|
|
session = sqlite_session
|
|
api = AgentRosterVersionRestoreApi()
|
|
|
|
if not allowed:
|
|
with pytest.raises(Forbidden, match="This feature requires a paid plan.") as exc_info:
|
|
unwrap(api.post)(api, session, "tenant-1", _account(), agent_id, version_id)
|
|
assert exc_info.value.code == 403
|
|
restore.assert_not_called()
|
|
assert not session.in_transaction()
|
|
else:
|
|
restored = unwrap(api.post)(api, session, "tenant-1", _account(), agent_id, version_id)
|
|
assert restored == {
|
|
"result": "success",
|
|
"active_config_snapshot_id": version_id,
|
|
"draft_config_id": None,
|
|
"restored_version_id": None,
|
|
}
|
|
restore.assert_called_once_with(
|
|
tenant_id="tenant-1",
|
|
agent_id=agent_id,
|
|
version_id=version_id,
|
|
account_id="account-1",
|
|
)
|
|
|
|
if edition == DeploymentEdition.CLOUD:
|
|
get_plan.assert_called_once_with("tenant-1")
|
|
else:
|
|
get_plan.assert_not_called()
|
|
|
|
|
|
def test_agent_observability_routes_resolve_app_from_agent_id(
|
|
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
|
|
) -> None:
|
|
agent_id = "00000000-0000-0000-0000-000000000001"
|
|
app_model = _app_detail_obj(id="app-1")
|
|
captured: dict[str, object] = {}
|
|
|
|
class FakeObservabilityService:
|
|
def list_logs(self, *, app: App, agent_id: str, params: AgentLogQueryParams) -> dict[str, object]:
|
|
captured["logs"] = {"app": app, "agent_id": agent_id, "params": params}
|
|
return {
|
|
"data": [
|
|
{
|
|
"conversation_id": "conversation-1",
|
|
"id": "conversation-1",
|
|
"title": "Debug",
|
|
"end_user_id": "end-user-1",
|
|
"message_count": 2,
|
|
"user_rate": None,
|
|
"operation_rate": None,
|
|
"unread": True,
|
|
"source": {
|
|
"id": "webapp:app-1",
|
|
"type": "webapp",
|
|
"app_id": "app-1",
|
|
"app_name": "Iris",
|
|
"app_icon_type": "emoji",
|
|
"app_icon": "robot",
|
|
"app_icon_background": "#fff",
|
|
"workflow_id": None,
|
|
"workflow_version": None,
|
|
"node_id": None,
|
|
},
|
|
"status": "success",
|
|
"created_at": 1,
|
|
"updated_at": 2,
|
|
}
|
|
],
|
|
"page": 2,
|
|
"limit": 5,
|
|
"total": 6,
|
|
"has_more": False,
|
|
}
|
|
|
|
def list_log_messages(
|
|
self, *, app: App, agent_id: str, conversation_id: str, params: AgentLogQueryParams
|
|
) -> dict[str, object]:
|
|
captured["messages"] = {
|
|
"app": app,
|
|
"agent_id": agent_id,
|
|
"conversation_id": conversation_id,
|
|
"params": params,
|
|
}
|
|
return {
|
|
"data": [
|
|
{
|
|
"id": "message-1",
|
|
"message_id": "message-1",
|
|
"conversation_id": "conversation-1",
|
|
"query": "hello",
|
|
"answer": "hi",
|
|
"status": "success",
|
|
"error": None,
|
|
"from_end_user_id": None,
|
|
"from_account_id": account_id,
|
|
"message_tokens": 1,
|
|
"answer_tokens": 2,
|
|
"total_tokens": 3,
|
|
"total_price": "0",
|
|
"currency": "USD",
|
|
"latency": 1.2,
|
|
"created_at": 1,
|
|
"updated_at": 2,
|
|
}
|
|
],
|
|
"page": 1,
|
|
"limit": 20,
|
|
"total": 1,
|
|
"has_more": False,
|
|
}
|
|
|
|
def list_log_sources(self, *, app: App, agent_id: str) -> dict[str, object]:
|
|
captured["sources"] = {"app": app, "agent_id": agent_id}
|
|
return {
|
|
"data": [
|
|
{
|
|
"id": "webapp:app-1",
|
|
"type": "webapp",
|
|
"app_id": "app-1",
|
|
"app_name": "Iris",
|
|
"app_icon_type": "emoji",
|
|
"app_icon": "robot",
|
|
"app_icon_background": "#fff",
|
|
"workflow_id": None,
|
|
"workflow_version": None,
|
|
"node_id": None,
|
|
}
|
|
],
|
|
"groups": [{"type": "webapp", "label": "WEBAPP", "sources": []}],
|
|
}
|
|
|
|
def get_statistics_summary(
|
|
self, *, app: App, agent_id: str, params: AgentStatisticsQueryParams
|
|
) -> dict[str, object]:
|
|
captured["statistics"] = {"app": app, "agent_id": agent_id, "params": params}
|
|
return {
|
|
"source": "all",
|
|
"summary": {
|
|
"total_messages": 1,
|
|
"total_conversations": 1,
|
|
"total_end_users": 1,
|
|
"total_tokens": 3,
|
|
"total_price": "0",
|
|
"currency": "USD",
|
|
"average_session_interactions": 1,
|
|
"average_response_time": 1200,
|
|
"tokens_per_second": 2,
|
|
"user_satisfaction_rate": 100,
|
|
},
|
|
"charts": {
|
|
"daily_messages": [{"date": "2026-06-17", "message_count": 1}],
|
|
"daily_conversations": [{"date": "2026-06-17", "conversation_count": 1}],
|
|
"daily_end_users": [{"date": "2026-06-17", "terminal_count": 1}],
|
|
"token_usage": [{"date": "2026-06-17", "token_count": 3, "total_price": "0", "currency": "USD"}],
|
|
"average_session_interactions": [{"date": "2026-06-17", "interactions": 1}],
|
|
"average_response_time": [{"date": "2026-06-17", "latency": 1200}],
|
|
"tokens_per_second": [{"date": "2026-06-17", "tps": 2}],
|
|
"user_satisfaction_rate": [{"date": "2026-06-17", "rate": 100}],
|
|
},
|
|
}
|
|
|
|
monkeypatch.setattr(roster_controller, "_resolve_agent_runtime_app_model", lambda _session, **kwargs: app_model)
|
|
monkeypatch.setattr(roster_controller, "_agent_observability_service", lambda *_args: FakeObservabilityService())
|
|
account = _account(account_id=account_id, timezone="UTC")
|
|
with app.test_request_context(
|
|
"/console/api/agent/00000000-0000-0000-0000-000000000001/logs?page=2&limit=5&keyword=hello&statuses=success&statuses=failed&sources=webapp:app-1&sources=workflow:app-2:workflow-1:v1:node-1&sort_by=created_at&sort_order=asc"
|
|
):
|
|
logs = unwrap(AgentLogsApi.get)(AgentLogsApi(), MagicMock(), "tenant-1", account, agent_id)
|
|
assert logs["data"][0]["id"] == "conversation-1"
|
|
assert logs["data"][0]["source"]["id"] == "webapp:app-1"
|
|
logs_call = cast(dict[str, object], captured["logs"])
|
|
assert logs_call["app"] is app_model
|
|
assert logs_call["agent_id"] == agent_id
|
|
logs_params = cast(Any, logs_call["params"])
|
|
assert logs_params.page == 2
|
|
assert logs_params.limit == 5
|
|
assert logs_params.keyword == "hello"
|
|
assert logs_params.statuses == ("success", "failed")
|
|
assert logs_params.sources == ("webapp:app-1", "workflow:app-2:workflow-1:v1:node-1")
|
|
assert logs_params.sort_by == "created_at"
|
|
assert logs_params.sort_order == "asc"
|
|
with app.test_request_context(
|
|
"/console/api/agent/00000000-0000-0000-0000-000000000001/logs/00000000-0000-0000-0000-000000000002/messages"
|
|
):
|
|
messages = unwrap(AgentLogMessagesApi.get)(
|
|
AgentLogMessagesApi(), MagicMock(), "tenant-1", account, agent_id, "00000000-0000-0000-0000-000000000002"
|
|
)
|
|
assert messages["data"][0]["id"] == "message-1"
|
|
messages_call = cast(dict[str, object], captured["messages"])
|
|
assert messages_call["app"] is app_model
|
|
assert messages_call["agent_id"] == agent_id
|
|
assert messages_call["conversation_id"] == "00000000-0000-0000-0000-000000000002"
|
|
messages_params = cast(Any, messages_call["params"])
|
|
assert messages_params.sources == ()
|
|
assert messages_params.statuses == ()
|
|
with app.test_request_context("/console/api/agent/00000000-0000-0000-0000-000000000001/log-sources"):
|
|
sources = unwrap(AgentLogSourcesApi.get)(AgentLogSourcesApi(), MagicMock(), "tenant-1", account, agent_id)
|
|
assert sources["data"][0]["id"] == "webapp:app-1"
|
|
sources_call = cast(dict[str, object], captured["sources"])
|
|
assert sources_call["app"] is app_model
|
|
assert sources_call["agent_id"] == agent_id
|
|
with app.test_request_context(
|
|
"/console/api/agent/00000000-0000-0000-0000-000000000001/statistics/summary?source=api"
|
|
):
|
|
statistics = unwrap(AgentStatisticsSummaryApi.get)(
|
|
AgentStatisticsSummaryApi(), AgentStatisticsQuery(source="api"), MagicMock(), "tenant-1", account, agent_id
|
|
)
|
|
assert statistics["summary"]["total_messages"] == 1
|
|
stats_call = cast(dict[str, object], captured["statistics"])
|
|
assert stats_call["app"] is app_model
|
|
assert stats_call["agent_id"] == agent_id
|
|
stats_params = cast(Any, stats_call["params"])
|
|
assert stats_params.source == "api"
|
|
assert stats_params.timezone == "UTC"
|
|
|
|
|
|
def test_workflow_composer_get_put_validate_candidates_impact_and_save(
|
|
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
|
|
) -> None:
|
|
app_model = _app_detail_obj(id="app-1")
|
|
payload = {
|
|
"variant": ComposerVariant.WORKFLOW.value,
|
|
"save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value,
|
|
"binding": {"binding_type": "roster_agent", "current_snapshot_id": "version-1"},
|
|
}
|
|
captured_load: dict[str, object] = {}
|
|
monkeypatch.setattr(
|
|
composer_controller.AgentComposerService,
|
|
"load_workflow_composer",
|
|
lambda **kwargs: captured_load.update(kwargs) or _workflow_composer_response(node_id=kwargs["node_id"]),
|
|
)
|
|
monkeypatch.setattr(
|
|
composer_controller.AgentComposerService,
|
|
"save_workflow_composer",
|
|
lambda **kwargs: _workflow_composer_response(save_options=[kwargs["payload"].save_strategy.value]),
|
|
)
|
|
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_publish_payload", lambda payload: None)
|
|
monkeypatch.setattr(
|
|
composer_controller.AgentComposerService,
|
|
"get_workflow_candidates",
|
|
lambda **kwargs: _candidates_response("workflow"),
|
|
)
|
|
monkeypatch.setattr(
|
|
composer_controller.AgentComposerService,
|
|
"calculate_impact",
|
|
lambda **kwargs: {
|
|
"current_snapshot_id": kwargs["current_snapshot_id"],
|
|
"workflow_node_count": 1,
|
|
"bindings": [],
|
|
},
|
|
)
|
|
with app.test_request_context("?snapshot_id=preview-version"):
|
|
workflow_state = unwrap(WorkflowAgentComposerApi.get)(
|
|
WorkflowAgentComposerApi(),
|
|
WorkflowAgentComposerQuery(snapshot_id="preview-version"),
|
|
MagicMock(),
|
|
"tenant-1",
|
|
account_id,
|
|
app_model,
|
|
"node-1",
|
|
)
|
|
assert workflow_state["node_id"] == "node-1"
|
|
assert captured_load["account_id"] == account_id
|
|
assert captured_load["snapshot_id"] == "preview-version"
|
|
composer_save_payload = ComposerSavePayload(
|
|
variant=ComposerVariant.WORKFLOW,
|
|
save_strategy=ComposerSaveStrategy.NODE_JOB_ONLY,
|
|
binding={"binding_type": "roster_agent", "current_snapshot_id": "version-1"},
|
|
)
|
|
with app.test_request_context(json=payload):
|
|
saved_state = unwrap(WorkflowAgentComposerApi.put)(
|
|
WorkflowAgentComposerApi(),
|
|
composer_save_payload,
|
|
MagicMock(),
|
|
"tenant-1",
|
|
account_id,
|
|
app_model,
|
|
"node-1",
|
|
)
|
|
assert saved_state["save_options"] == ["node_job_only"]
|
|
assert unwrap(WorkflowAgentComposerValidateApi.post)(
|
|
WorkflowAgentComposerValidateApi(),
|
|
composer_save_payload,
|
|
MagicMock(),
|
|
"tenant-1",
|
|
app_model,
|
|
"node-1",
|
|
) == {"result": "success", "errors": [], "warnings": [], "knowledge_retrieval_placeholder": []}
|
|
assert (
|
|
unwrap(WorkflowAgentComposerCandidatesApi.get)(
|
|
WorkflowAgentComposerCandidatesApi(), MagicMock(), "tenant-1", account_id, app_model, "node-1"
|
|
)["variant"]
|
|
== "workflow"
|
|
)
|
|
with app.test_request_context(json=payload):
|
|
assert unwrap(WorkflowAgentComposerImpactApi.post)(
|
|
WorkflowAgentComposerImpactApi(),
|
|
composer_save_payload,
|
|
MagicMock(),
|
|
"tenant-1",
|
|
app_model,
|
|
"node-1",
|
|
) == {"current_snapshot_id": "version-1", "workflow_node_count": 1, "bindings": []}
|
|
assert unwrap(WorkflowAgentComposerSaveToRosterApi.post)(
|
|
WorkflowAgentComposerSaveToRosterApi(),
|
|
composer_save_payload,
|
|
MagicMock(),
|
|
"tenant-1",
|
|
account_id,
|
|
app_model,
|
|
"node-1",
|
|
)["save_options"] == ["node_job_only"]
|
|
|
|
|
|
def test_workflow_composer_get_uses_write_transaction() -> None:
|
|
assert "@with_session\n @get_app_model" in getsource(WorkflowAgentComposerApi)
|
|
|
|
|
|
def test_build_draft_apply_leaves_transaction_ownership_to_service() -> None:
|
|
assert "@with_session(write=False)\n def post" in getsource(AgentBuildDraftApplyApi)
|
|
|
|
|
|
def test_workflow_composer_copy_from_roster(app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str) -> None:
|
|
app_model = _app_detail_obj(id="app-1")
|
|
captured: dict[str, object] = {}
|
|
|
|
def fake_copy_from_roster(**kwargs: object) -> dict[str, object]:
|
|
captured.update(kwargs)
|
|
return _workflow_composer_response(
|
|
binding={
|
|
"id": "binding-1",
|
|
"binding_type": "inline_agent",
|
|
"agent_id": "inline-agent-1",
|
|
"current_snapshot_id": "inline-version-1",
|
|
"workflow_id": "workflow-1",
|
|
"node_id": kwargs["node_id"],
|
|
},
|
|
agent={
|
|
"id": "inline-agent-1",
|
|
"name": "Nadia",
|
|
"description": "",
|
|
"scope": "workflow_only",
|
|
"status": "active",
|
|
},
|
|
active_config_snapshot={"id": "inline-version-1", "version": 1},
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
composer_controller.AgentComposerService, "copy_workflow_composer_from_roster", fake_copy_from_roster
|
|
)
|
|
with app.test_request_context(
|
|
json={
|
|
"source_agent_id": "roster-agent-1",
|
|
"source_snapshot_id": "roster-version-1",
|
|
"idempotency_key": "copy-1",
|
|
}
|
|
):
|
|
result = unwrap(WorkflowAgentComposerCopyFromRosterApi.post)(
|
|
WorkflowAgentComposerCopyFromRosterApi(),
|
|
WorkflowComposerCopyFromRosterPayload(
|
|
source_agent_id="roster-agent-1",
|
|
source_snapshot_id="roster-version-1",
|
|
idempotency_key="copy-1",
|
|
),
|
|
MagicMock(),
|
|
"tenant-1",
|
|
account_id,
|
|
app_model,
|
|
"node-1",
|
|
)
|
|
assert result["binding"]["binding_type"] == "inline_agent"
|
|
captured.pop("session", None)
|
|
assert captured == {
|
|
"tenant_id": "tenant-1",
|
|
"app_id": "app-1",
|
|
"node_id": "node-1",
|
|
"account_id": account_id,
|
|
"source_agent_id": "roster-agent-1",
|
|
"source_snapshot_id": "roster-version-1",
|
|
"idempotency_key": "copy-1",
|
|
}
|
|
|
|
|
|
def test_workflow_impact_returns_empty_without_version(app: Flask) -> None:
|
|
payload = {"variant": ComposerVariant.WORKFLOW.value, "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value}
|
|
with app.test_request_context(json=payload):
|
|
result = unwrap(WorkflowAgentComposerImpactApi.post)(
|
|
WorkflowAgentComposerImpactApi(),
|
|
ComposerSavePayload(
|
|
variant=ComposerVariant.WORKFLOW,
|
|
save_strategy=ComposerSaveStrategy.NODE_JOB_ONLY,
|
|
),
|
|
MagicMock(),
|
|
"tenant-1",
|
|
_app_detail_obj(id="app-1"),
|
|
"node-1",
|
|
)
|
|
assert result == {"current_snapshot_id": None, "workflow_node_count": 0, "bindings": []}
|
|
|
|
|
|
def test_agent_composer_routes_resolve_app_from_agent_id(
|
|
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str
|
|
) -> None:
|
|
agent_id = "00000000-0000-0000-0000-000000000001"
|
|
captured: dict[str, object] = {}
|
|
payload = {
|
|
"variant": ComposerVariant.AGENT_APP.value,
|
|
"save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
|
|
"agent_soul": {"prompt": {"system_prompt": "x"}},
|
|
}
|
|
|
|
def load_agent_composer(**kwargs: object) -> dict[str, object]:
|
|
captured["load"] = kwargs
|
|
return _agent_app_composer_response()
|
|
|
|
def save_agent_composer(**kwargs: object) -> dict[str, object]:
|
|
captured["save"] = kwargs
|
|
return _agent_app_composer_response()
|
|
|
|
def get_agent_app_candidates(**kwargs: object) -> dict[str, object]:
|
|
captured["candidates"] = kwargs
|
|
return _candidates_response("agent_app")
|
|
|
|
monkeypatch.setattr(composer_controller.AgentComposerService, "load_agent_composer", load_agent_composer)
|
|
monkeypatch.setattr(composer_controller.AgentComposerService, "save_agent_composer", save_agent_composer)
|
|
monkeypatch.setattr(composer_controller.ComposerConfigValidator, "validate_publish_payload", lambda payload: None)
|
|
monkeypatch.setattr(composer_controller.AgentComposerService, "get_agent_app_candidates", get_agent_app_candidates)
|
|
composer = unwrap(AgentComposerApi.get)(AgentComposerApi(), MagicMock(), "tenant-1", agent_id)
|
|
assert composer["variant"] == "agent_app"
|
|
assert composer["active_config_is_published"] is True
|
|
assert cast(dict[str, object], captured["load"])["agent_id"] == agent_id
|
|
composer_save_payload = ComposerSavePayload(
|
|
variant=ComposerVariant.AGENT_APP,
|
|
save_strategy=ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION,
|
|
agent_soul={"prompt": {"system_prompt": "x"}},
|
|
)
|
|
with app.test_request_context(json=payload):
|
|
saved_composer = unwrap(AgentComposerApi.put)(
|
|
AgentComposerApi(), composer_save_payload, MagicMock(), "tenant-1", account_id, agent_id
|
|
)
|
|
assert saved_composer["variant"] == "agent_app"
|
|
assert saved_composer["active_config_is_published"] is True
|
|
assert cast(dict[str, object], captured["save"])["agent_id"] == agent_id
|
|
candidates = unwrap(AgentComposerCandidatesApi.get)(
|
|
AgentComposerCandidatesApi(), MagicMock(), "tenant-1", account_id, agent_id
|
|
)
|
|
assert candidates["variant"] == "agent_app"
|
|
assert cast(dict[str, object], captured["candidates"])["agent_id"] == agent_id
|
|
|
|
|
|
def test_agent_composer_get_uses_read_only_session() -> None:
|
|
assert "@with_session(write=False)\n def get" in getsource(AgentComposerApi)
|
|
|
|
|
|
def test_agent_composer_get_accepts_missing_draft(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
payload = _agent_app_composer_response()
|
|
payload["draft"] = None
|
|
payload["agent_soul"] = {"prompt": {"system_prompt": "read-only snapshot"}}
|
|
monkeypatch.setattr(composer_controller.AgentComposerService, "load_agent_composer", lambda **_kwargs: payload)
|
|
with app.test_request_context():
|
|
result = unwrap(AgentComposerApi.get)(AgentComposerApi(), MagicMock(), "tenant-1", "agent-1")
|
|
assert result["draft"] is None
|
|
assert result["agent_soul"]["prompt"]["system_prompt"] == "read-only snapshot"
|
|
|
|
|
|
def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id(
|
|
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str, unbound_session: Session
|
|
) -> None:
|
|
agent_id = "00000000-0000-0000-0000-000000000001"
|
|
app_model = _app_detail_obj(id="app-1", tenant_id="tenant-1", mode=AppMode.AGENT)
|
|
captured: dict[str, object] = {}
|
|
|
|
def resolve_agent_app_model(**kwargs: object) -> object:
|
|
captured["stop_resolve"] = kwargs
|
|
return app_model
|
|
|
|
def resolve_agent_app_model_with_session(_self, **kwargs: object) -> object:
|
|
captured["resolve_session"] = _self._session
|
|
captured["resolve"] = kwargs
|
|
return app_model
|
|
|
|
def create_chat_message(**kwargs: object) -> dict[str, object]:
|
|
captured["create"] = kwargs
|
|
return {"result": "generated"}
|
|
|
|
def stop_chat_message(**kwargs: object) -> tuple[dict[str, object], int]:
|
|
captured["stop"] = kwargs
|
|
return ({"result": "success"}, 200)
|
|
|
|
monkeypatch.setattr(completion_controller, "resolve_agent_runtime_app_model", resolve_agent_app_model)
|
|
monkeypatch.setattr(
|
|
completion_controller.AgentRosterService,
|
|
"get_agent_runtime_app_model",
|
|
resolve_agent_app_model_with_session,
|
|
)
|
|
monkeypatch.setattr(completion_controller, "_create_chat_message", create_chat_message)
|
|
monkeypatch.setattr(completion_controller, "_stop_chat_message", stop_chat_message)
|
|
with app.test_request_context(json={"inputs": {}, "query": "hello"}):
|
|
assert unwrap(AgentChatMessageApi.post)(
|
|
AgentChatMessageApi(), unbound_session, "tenant-1", _account(account_id=account_id), agent_id
|
|
) == {"result": "generated"}
|
|
assert cast(dict[str, object], captured["resolve"]) == {"tenant_id": "tenant-1", "agent_id": agent_id}
|
|
assert captured["resolve_session"] is unbound_session
|
|
create_call = cast(dict[str, object], captured["create"])
|
|
assert create_call["session"] is unbound_session
|
|
assert create_call["app_model"] is app_model
|
|
assert cast(Account, create_call["current_user"]).id == account_id
|
|
assert unwrap(AgentChatMessageStopApi.post)(
|
|
AgentChatMessageStopApi(), unbound_session, "tenant-1", account_id, agent_id, "task-1"
|
|
) == ({"result": "success"}, 200)
|
|
assert captured["stop_resolve"] == {
|
|
"session": unbound_session,
|
|
"tenant_id": "tenant-1",
|
|
"agent_id": agent_id,
|
|
}
|
|
stop_call = cast(dict[str, object], captured["stop"])
|
|
assert stop_call == {"current_user_id": account_id, "app_model": app_model, "task_id": "task-1"}
|
|
|
|
|
|
def test_agent_chat_stream_preflight_raises_first_error_event() -> None:
|
|
class ClosableStream:
|
|
def __init__(self) -> None:
|
|
self.closed = False
|
|
self._chunks = iter(
|
|
[
|
|
"event: ping\n\n",
|
|
(
|
|
'data: {"event":"error","message":"Incorrect API key provided",'
|
|
'"code":"completion_request_error","status":400}\n\n'
|
|
),
|
|
]
|
|
)
|
|
|
|
def __iter__(self) -> Self:
|
|
return self
|
|
|
|
def __next__(self) -> str:
|
|
return next(self._chunks)
|
|
|
|
def close(self) -> None:
|
|
self.closed = True
|
|
|
|
stream = ClosableStream()
|
|
with pytest.raises(CompletionRequestError) as exc_info:
|
|
completion_controller._raise_agent_stream_error_before_response(stream)
|
|
assert exc_info.value.description is not None
|
|
assert "Incorrect API key provided" in exc_info.value.description
|
|
assert stream.closed is True
|
|
|
|
|
|
def test_agent_chat_stream_preflight_preserves_first_normal_event() -> None:
|
|
stream = iter(
|
|
["event: ping\n\n", 'data: {"event":"message","answer":"hello"}\n\n', 'data: {"event":"message_end"}\n\n']
|
|
)
|
|
wrapped = completion_controller._raise_agent_stream_error_before_response(stream)
|
|
assert list(wrapped) == [
|
|
"event: ping\n\n",
|
|
'data: {"event":"message","answer":"hello"}\n\n',
|
|
'data: {"event":"message_end"}\n\n',
|
|
]
|
|
|
|
|
|
def test_agent_build_chat_finalize_route_resolves_app_from_agent_id(
|
|
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str, unbound_session: Session
|
|
) -> None:
|
|
agent_id = "00000000-0000-0000-0000-000000000001"
|
|
app_model = _app_detail_obj(id="app-1", tenant_id="tenant-1", mode=AppMode.AGENT)
|
|
captured: dict[str, object] = {}
|
|
|
|
def resolve_agent_app_model(**kwargs: object) -> object:
|
|
captured["resolve"] = kwargs
|
|
return app_model
|
|
|
|
def create_finalization_message(**kwargs: object) -> dict[str, object]:
|
|
captured["finalize"] = kwargs
|
|
return {"result": "generated"}
|
|
|
|
monkeypatch.setattr(
|
|
completion_controller.AgentRosterService,
|
|
"get_agent_runtime_app_model",
|
|
lambda _self, **kwargs: resolve_agent_app_model(**kwargs),
|
|
)
|
|
monkeypatch.setattr(completion_controller, "_create_build_chat_finalization_message", create_finalization_message)
|
|
with app.test_request_context():
|
|
assert unwrap(AgentBuildChatFinalizeApi.post)(
|
|
AgentBuildChatFinalizeApi(), unbound_session, "tenant-1", _account(account_id=account_id), agent_id
|
|
) == {"result": "generated"}
|
|
assert cast(dict[str, object], captured["resolve"]) == {"tenant_id": "tenant-1", "agent_id": agent_id}
|
|
finalize_call = cast(dict[str, object], captured["finalize"])
|
|
assert finalize_call["session"] is unbound_session
|
|
assert finalize_call["app_model"] is app_model
|
|
assert finalize_call["current_tenant_id"] == "tenant-1"
|
|
assert finalize_call["agent_id"] == agent_id
|
|
assert cast(Account, finalize_call["current_user"]).id == account_id
|
|
|
|
|
|
def test_build_chat_finalization_helper_forces_debug_build_and_push_prompt(
|
|
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str, unbound_session: Session
|
|
) -> None:
|
|
app_model = _app_detail_obj(id="app-1", tenant_id="tenant-1", mode=AppMode.AGENT)
|
|
captured: dict[str, object] = {}
|
|
|
|
def resolve_debug_conversation(**kwargs: object) -> str:
|
|
captured["resolve_debug_conversation"] = kwargs
|
|
return "debug-conversation-1"
|
|
|
|
def generate(**kwargs: object) -> object:
|
|
captured["generate"] = kwargs
|
|
return iter(
|
|
["event: ping\n\n", 'data: {"event":"message","answer":"working"}\n\n', 'data: {"event":"message_end"}\n\n']
|
|
)
|
|
|
|
monkeypatch.setattr(
|
|
completion_controller, "_resolve_current_user_agent_debug_conversation_id", resolve_debug_conversation
|
|
)
|
|
monkeypatch.setattr(completion_controller.AppGenerateService, "generate", generate)
|
|
with app.test_request_context(headers={"X-Trace-Id": "trace-1"}):
|
|
result = completion_controller._create_build_chat_finalization_message(
|
|
current_tenant_id="tenant-1",
|
|
current_user=_account(account_id=account_id),
|
|
app_model=app_model,
|
|
agent_id="agent-1",
|
|
session=unbound_session,
|
|
)
|
|
assert result == ({"result": "success"}, 200)
|
|
resolve_call = cast(dict[str, object], captured["resolve_debug_conversation"])
|
|
assert resolve_call.pop("current_user") is not None
|
|
assert resolve_call == {
|
|
"session": unbound_session,
|
|
"current_tenant_id": "tenant-1",
|
|
"app_model": app_model,
|
|
"agent_id": "agent-1",
|
|
"draft_type": AgentConfigDraftType.DEBUG_BUILD,
|
|
}
|
|
generate_call = cast(dict[str, object], captured["generate"])
|
|
assert generate_call["app_model"] is app_model
|
|
assert generate_call["streaming"] is True
|
|
args = cast(dict[str, object], generate_call["args"])
|
|
assert args["draft_type"] == "debug_build"
|
|
assert args["response_mode"] == "streaming"
|
|
assert args["conversation_id"] == "debug-conversation-1"
|
|
assert args["inputs"] == {}
|
|
assert args["auto_generate_name"] is False
|
|
assert "_agent_runtime_exit_intent" not in args
|
|
assert args["external_trace_id"] == "trace-1"
|
|
|
|
|
|
def test_drain_streaming_generate_response_returns_on_message_end() -> None:
|
|
closed: list[bool] = []
|
|
|
|
def generate_response() -> Generator[str]:
|
|
try:
|
|
yield "event: ping\n\n"
|
|
yield 'data: {"event":"message","answer":"working"}\n\n'
|
|
yield 'data: {"event":"message_end","message_id":"msg-1"}\n\n'
|
|
finally:
|
|
closed.append(True)
|
|
|
|
# Retain the generator so garbage collection cannot satisfy the cleanup assertion.
|
|
response = generate_response()
|
|
assert completion_controller._drain_streaming_generate_response(response) is None
|
|
assert closed == [True]
|
|
|
|
|
|
def test_drain_streaming_generate_response_maps_error_event() -> None:
|
|
response = (chunk for chunk in ['data: {"event":"error","message":"backend failed"}\n\n'])
|
|
with pytest.raises(CompletionRequestError, match="backend failed"):
|
|
completion_controller._drain_streaming_generate_response(response)
|
|
|
|
|
|
def test_drain_streaming_generate_response_raises_when_stream_ends_early() -> None:
|
|
response = (chunk for chunk in ['data: {"event":"message","answer":"working"}\n\n'])
|
|
with pytest.raises(CompletionRequestError, match="did not complete"):
|
|
completion_controller._drain_streaming_generate_response(response)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("payload_extra", "expected_draft_type", "expected_start_new"),
|
|
[
|
|
({}, AgentConfigDraftType.DRAFT, True),
|
|
({"conversation_id": None}, AgentConfigDraftType.DRAFT, True),
|
|
({"conversation_id": ""}, AgentConfigDraftType.DRAFT, True),
|
|
(
|
|
{"conversation_id": "00000000-0000-0000-0000-000000000001"},
|
|
AgentConfigDraftType.DRAFT,
|
|
False,
|
|
),
|
|
({"draft_type": "debug_build"}, AgentConfigDraftType.DEBUG_BUILD, False),
|
|
(
|
|
{
|
|
"draft_type": "debug_build",
|
|
"conversation_id": "00000000-0000-0000-0000-000000000001",
|
|
},
|
|
AgentConfigDraftType.DEBUG_BUILD,
|
|
False,
|
|
),
|
|
],
|
|
)
|
|
def test_agent_chat_helper_resolves_scoped_conversation_and_forces_streaming(
|
|
app: Flask,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
account_id: str,
|
|
payload_extra: dict[str, str | None],
|
|
expected_draft_type: AgentConfigDraftType,
|
|
expected_start_new: bool,
|
|
unbound_session: Session,
|
|
) -> None:
|
|
app_model = _app_detail_obj(id="app-1", tenant_id="tenant-1", mode=AppMode.AGENT)
|
|
current_user = _account(account_id=account_id)
|
|
captured: dict[str, object] = {}
|
|
|
|
def generate(**kwargs: object) -> dict[str, object]:
|
|
captured.update(kwargs)
|
|
return {"answer": "ok"}
|
|
|
|
def resolve_debug_conversation(**kwargs: object) -> str:
|
|
captured["resolve_debug_conversation"] = kwargs
|
|
return "00000000-0000-0000-0000-000000000001"
|
|
|
|
monkeypatch.setattr(completion_controller.AppGenerateService, "generate", generate)
|
|
monkeypatch.setattr(
|
|
completion_controller,
|
|
"_resolve_current_user_agent_debug_conversation_id",
|
|
resolve_debug_conversation,
|
|
)
|
|
monkeypatch.setattr(
|
|
completion_controller.helper, "compact_generate_response", lambda response: {"response": response}
|
|
)
|
|
with app.test_request_context(
|
|
json={"inputs": {}, "query": "hello", "response_mode": "streaming", **payload_extra},
|
|
headers={"X-Trace-Id": "trace-1"},
|
|
):
|
|
result = completion_controller._create_chat_message(
|
|
current_user=current_user, app_model=app_model, session=unbound_session
|
|
)
|
|
assert result == {"response": {"answer": "ok"}}
|
|
assert captured["app_model"] is app_model
|
|
assert captured["user"] is current_user
|
|
assert captured["streaming"] is True
|
|
args = cast(dict[str, object], captured["args"])
|
|
assert args["response_mode"] == "streaming"
|
|
assert args["conversation_id"] == "00000000-0000-0000-0000-000000000001"
|
|
assert args["auto_generate_name"] is False
|
|
assert args["external_trace_id"] == "trace-1"
|
|
resolve_call = cast(dict[str, object], captured["resolve_debug_conversation"])
|
|
assert resolve_call["draft_type"] == expected_draft_type
|
|
assert resolve_call["start_new"] is expected_start_new
|
|
|
|
|
|
def test_agent_chat_helper_ignores_private_exit_intent_payload_key(
|
|
app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str, unbound_session: Session
|
|
) -> None:
|
|
app_model = _app_detail_obj(id="app-1", tenant_id="tenant-1", mode=AppMode.AGENT)
|
|
current_user = _account(account_id=account_id)
|
|
captured: dict[str, object] = {}
|
|
|
|
def generate(**kwargs: object) -> dict[str, object]:
|
|
captured.update(kwargs)
|
|
return {"answer": "ok"}
|
|
|
|
monkeypatch.setattr(completion_controller.AppGenerateService, "generate", generate)
|
|
monkeypatch.setattr(
|
|
completion_controller,
|
|
"_resolve_current_user_agent_debug_conversation_id",
|
|
lambda **kwargs: "debug-conversation-1",
|
|
)
|
|
monkeypatch.setattr(
|
|
completion_controller.helper,
|
|
"compact_generate_response",
|
|
lambda response: {"response": response},
|
|
)
|
|
|
|
with app.test_request_context(
|
|
json={
|
|
"inputs": {},
|
|
"query": "hello",
|
|
"response_mode": "streaming",
|
|
"_agent_runtime_exit_intent": "delete",
|
|
}
|
|
):
|
|
result = completion_controller._create_chat_message(
|
|
current_user=current_user,
|
|
app_model=app_model,
|
|
session=unbound_session,
|
|
)
|
|
|
|
assert result == {"response": {"answer": "ok"}}
|
|
assert captured["streaming"] is True
|
|
args = cast(dict[str, object], captured["args"])
|
|
assert args["response_mode"] == "streaming"
|
|
assert args["conversation_id"] == "debug-conversation-1"
|
|
assert "_agent_runtime_exit_intent" not in args
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("payload_extra", "expected_draft_type"),
|
|
[
|
|
({}, AgentConfigDraftType.DRAFT),
|
|
({"draft_type": "debug_build"}, AgentConfigDraftType.DEBUG_BUILD),
|
|
],
|
|
)
|
|
def test_agent_chat_helper_rejects_foreign_debug_conversation_before_generation(
|
|
app: Flask,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
account_id: str,
|
|
payload_extra: dict[str, str],
|
|
expected_draft_type: AgentConfigDraftType,
|
|
unbound_session: Session,
|
|
) -> None:
|
|
app_model = _app_detail_obj(id="app-1", tenant_id="tenant-1", mode=AppMode.AGENT)
|
|
generate = MagicMock()
|
|
resolve_debug_conversation = MagicMock(return_value="owned-conversation")
|
|
monkeypatch.setattr(completion_controller.AppGenerateService, "generate", generate)
|
|
monkeypatch.setattr(
|
|
completion_controller,
|
|
"_resolve_current_user_agent_debug_conversation_id",
|
|
resolve_debug_conversation,
|
|
)
|
|
with app.test_request_context(
|
|
json={
|
|
"inputs": {},
|
|
"query": "hello",
|
|
"response_mode": "streaming",
|
|
"conversation_id": "00000000-0000-0000-0000-000000000001",
|
|
**payload_extra,
|
|
}
|
|
):
|
|
with pytest.raises(NotFound):
|
|
completion_controller._create_chat_message(
|
|
current_tenant_id="tenant-1",
|
|
current_user=_account(account_id=account_id),
|
|
app_model=app_model,
|
|
agent_id="agent-1",
|
|
session=unbound_session,
|
|
)
|
|
|
|
resolve_debug_conversation.assert_called_once()
|
|
resolve_call = resolve_debug_conversation.call_args.kwargs
|
|
assert resolve_call["draft_type"] == expected_draft_type
|
|
assert resolve_call["start_new"] is False
|
|
generate.assert_not_called()
|
|
|
|
|
|
def test_resolve_current_user_agent_debug_conversation_uses_agent_or_backing_app(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
calls: list[dict[str, object]] = []
|
|
|
|
class FakeRosterService:
|
|
def __init__(self, session: object) -> None:
|
|
calls.append({"session": session})
|
|
|
|
def get_or_create_build_conversation(self, **kwargs: object) -> str:
|
|
calls.append({"get_build": kwargs})
|
|
return f"debug-{kwargs['agent_id']}"
|
|
|
|
def rotate_preview_conversation(self, **kwargs: object) -> str:
|
|
calls.append({"rotate_preview": kwargs})
|
|
return f"new-{kwargs['agent_id']}"
|
|
|
|
def get_current_preview_conversation(self, **kwargs: object) -> str:
|
|
calls.append({"get_preview": kwargs})
|
|
return f"preview-{kwargs['agent_id']}"
|
|
|
|
def get_app_backing_agent(self, **kwargs: object) -> object:
|
|
calls.append({"get_app_backing_agent": kwargs})
|
|
return Agent(
|
|
tenant_id="tenant-1",
|
|
name="Agent",
|
|
scope=AgentScope.ROSTER,
|
|
source=AgentSource.AGENT_APP,
|
|
id="backing-agent",
|
|
)
|
|
|
|
monkeypatch.setattr(completion_controller, "AgentRosterService", FakeRosterService)
|
|
explicit_id = completion_controller._resolve_current_user_agent_debug_conversation_id(
|
|
session="session-1", # type: ignore[arg-type]
|
|
current_tenant_id="tenant-1",
|
|
current_user=_account(),
|
|
app_model=_app_detail_obj(id="app-1"),
|
|
agent_id="agent-1",
|
|
draft_type=AgentConfigDraftType.DRAFT,
|
|
start_new=True,
|
|
)
|
|
fallback_id = completion_controller._resolve_current_user_agent_debug_conversation_id(
|
|
session="session-1", # type: ignore[arg-type]
|
|
current_tenant_id="tenant-1",
|
|
current_user=_account(),
|
|
app_model=_app_detail_obj(id="app-1"),
|
|
agent_id=None,
|
|
draft_type=AgentConfigDraftType.DEBUG_BUILD,
|
|
)
|
|
fallback_preview_id = completion_controller._resolve_current_user_agent_debug_conversation_id(
|
|
session="session-1", # type: ignore[arg-type]
|
|
current_tenant_id="tenant-1",
|
|
current_user=_account(),
|
|
app_model=_app_detail_obj(id="app-1"),
|
|
agent_id=None,
|
|
draft_type=AgentConfigDraftType.DRAFT,
|
|
start_new=True,
|
|
)
|
|
current_preview_id = completion_controller._resolve_current_user_agent_debug_conversation_id(
|
|
session="session-1", # type: ignore[arg-type]
|
|
current_tenant_id="tenant-1",
|
|
current_user=_account(),
|
|
app_model=_app_detail_obj(id="app-1"),
|
|
agent_id="agent-1",
|
|
draft_type=AgentConfigDraftType.DRAFT,
|
|
)
|
|
assert explicit_id == "new-agent-1"
|
|
assert fallback_id == "debug-backing-agent"
|
|
assert fallback_preview_id == "new-backing-agent"
|
|
assert current_preview_id == "preview-agent-1"
|
|
assert calls[1] == {
|
|
"rotate_preview": {
|
|
"tenant_id": "tenant-1",
|
|
"agent_id": "agent-1",
|
|
"account_id": "account-1",
|
|
}
|
|
}
|
|
assert calls[3] == {"get_app_backing_agent": {"tenant_id": "tenant-1", "app_id": "app-1"}}
|
|
assert calls[4] == {
|
|
"get_build": {
|
|
"tenant_id": "tenant-1",
|
|
"agent_id": "backing-agent",
|
|
"account_id": "account-1",
|
|
}
|
|
}
|
|
assert calls[6] == {"get_app_backing_agent": {"tenant_id": "tenant-1", "app_id": "app-1"}}
|
|
assert calls[7] == {
|
|
"rotate_preview": {
|
|
"tenant_id": "tenant-1",
|
|
"agent_id": "backing-agent",
|
|
"account_id": "account-1",
|
|
}
|
|
}
|
|
assert calls[9] == {
|
|
"get_preview": {
|
|
"tenant_id": "tenant-1",
|
|
"agent_id": "agent-1",
|
|
"account_id": "account-1",
|
|
}
|
|
}
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("error", "expected"),
|
|
[
|
|
(completion_controller.services.errors.conversation.ConversationNotExistsError(), NotFound),
|
|
(
|
|
completion_controller.services.errors.conversation.ConversationCompletedError(),
|
|
completion_controller.ConversationCompletedError,
|
|
),
|
|
(
|
|
completion_controller.services.errors.app_model_config.AppModelConfigBrokenError(),
|
|
completion_controller.AppUnavailableError,
|
|
),
|
|
(
|
|
completion_controller.ProviderTokenNotInitError("not initialized"),
|
|
completion_controller.ProviderNotInitializeError,
|
|
),
|
|
(completion_controller.QuotaExceededError(), completion_controller.ProviderQuotaExceededError),
|
|
(
|
|
completion_controller.ModelCurrentlyNotSupportError(),
|
|
completion_controller.ProviderModelCurrentlyNotSupportError,
|
|
),
|
|
(completion_controller.InvokeRateLimitError("rate limited"), completion_controller.InvokeRateLimitHttpError),
|
|
(completion_controller.InvokeError("invoke failed"), completion_controller.CompletionRequestError),
|
|
(RuntimeError("unexpected"), InternalServerError),
|
|
],
|
|
)
|
|
def test_agent_chat_helper_maps_generation_errors(
|
|
app: Flask,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
error: Exception,
|
|
expected: type[Exception],
|
|
unbound_session: Session,
|
|
) -> None:
|
|
app_model = _app_detail_obj(id="app-1", mode=AppMode.CHAT)
|
|
monkeypatch.setattr(completion_controller.AppGenerateService, "generate", lambda **_: (_ for _ in ()).throw(error))
|
|
with app.test_request_context(json={"inputs": {}, "query": "hello"}):
|
|
with pytest.raises(expected):
|
|
completion_controller._create_chat_message(
|
|
current_user=_account(), app_model=app_model, session=unbound_session
|
|
)
|
|
|
|
|
|
def test_dify_tool_candidate_response_keeps_granularity_fields() -> None:
|
|
"""Both selection granularities must survive the fields-layer model —
|
|
the frontend needs granularity/tools_count to render the Tools menu."""
|
|
from fields.agent_fields import AgentComposerDifyToolCandidateResponse
|
|
|
|
provider_entry = AgentComposerDifyToolCandidateResponse.model_validate(
|
|
{
|
|
"id": "duckduckgo/*",
|
|
"granularity": "provider",
|
|
"name": "DuckDuckGo",
|
|
"provider": "duckduckgo",
|
|
"plugin_id": "langgenius/duckduckgo",
|
|
"tools_count": 2,
|
|
}
|
|
).model_dump(exclude_none=True)
|
|
assert provider_entry["granularity"] == "provider"
|
|
assert provider_entry["tools_count"] == 2
|