Merge remote-tracking branch 'origin/main' into feat/agent-v2
# Conflicts: # api/controllers/console/app/agent_drive_inspector.py # api/migrations/versions/2026_06_18_2300-b2515f9d4c2a_agent_drive_skill_metadata_refactor.py # api/openapi/markdown/console-openapi.md # api/services/agent/skill_standardize_service.py # api/services/agent_drive_service.py # api/tests/unit_tests/controllers/console/app/test_agent_drive_inspector.py # api/tests/unit_tests/services/agent/test_skill_standardize_service.py # packages/contracts/generated/api/console/agent/orpc.gen.ts # packages/contracts/generated/api/console/agent/types.gen.ts # packages/contracts/generated/api/console/agent/zod.gen.ts # packages/contracts/generated/api/console/apps/orpc.gen.ts # packages/contracts/generated/api/console/apps/types.gen.ts # packages/contracts/generated/api/console/apps/zod.gen.ts
This commit is contained in:
@@ -28,6 +28,7 @@ from controllers.console.agent.roster import (
|
||||
AgentLogsApi,
|
||||
AgentLogSourcesApi,
|
||||
AgentRosterVersionDetailApi,
|
||||
AgentRosterVersionRestoreApi,
|
||||
AgentRosterVersionsApi,
|
||||
AgentStatisticsSummaryApi,
|
||||
)
|
||||
@@ -158,6 +159,9 @@ def test_agent_v2_console_routes_are_agent_id_first() -> None:
|
||||
"/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
|
||||
@@ -513,6 +517,13 @@ def test_agent_versions_call_services(app: Flask, monkeypatch: pytest.MonkeyPatc
|
||||
],
|
||||
},
|
||||
)
|
||||
captured_restore: dict[str, object] = {}
|
||||
|
||||
def restore_agent_version(_self, **kwargs):
|
||||
captured_restore.update(kwargs)
|
||||
return {"result": "success", "active_config_snapshot_id": kwargs["version_id"]}
|
||||
|
||||
monkeypatch.setattr(roster_controller.AgentRosterService, "restore_agent_version", restore_agent_version)
|
||||
|
||||
assert (
|
||||
unwrap(AgentRosterVersionsApi.get)(AgentRosterVersionsApi(), "tenant-1", agent_id)["data"][0]["id"]
|
||||
@@ -523,6 +534,16 @@ def test_agent_versions_call_services(app: Flask, monkeypatch: pytest.MonkeyPatc
|
||||
)
|
||||
assert version_detail["id"] == version_id
|
||||
assert version_detail["agent_id"] == agent_id
|
||||
restored = unwrap(AgentRosterVersionRestoreApi.post)(
|
||||
AgentRosterVersionRestoreApi(), "tenant-1", SimpleNamespace(id="account-1"), agent_id, version_id
|
||||
)
|
||||
assert restored == {"result": "success", "active_config_snapshot_id": version_id}
|
||||
assert captured_restore == {
|
||||
"tenant_id": "tenant-1",
|
||||
"agent_id": agent_id,
|
||||
"version_id": version_id,
|
||||
"account_id": "account-1",
|
||||
}
|
||||
|
||||
|
||||
def test_agent_observability_routes_resolve_app_from_agent_id(
|
||||
|
||||
@@ -20,6 +20,8 @@ from controllers.console.app.agent_drive_inspector import (
|
||||
AgentDriveListByAgentApi,
|
||||
AgentDrivePreviewApi,
|
||||
AgentDrivePreviewByAgentApi,
|
||||
AgentDriveSkillInspectApi,
|
||||
AgentDriveSkillInspectByAgentApi,
|
||||
AgentDriveSkillListApi,
|
||||
AgentDriveSkillListByAgentApi,
|
||||
)
|
||||
@@ -84,39 +86,6 @@ def test_list_by_agent_filters_value_pointers_out_of_console_payload():
|
||||
assert drive.return_value.manifest.call_args.kwargs["agent_id"] == "agent-1"
|
||||
|
||||
|
||||
def test_skill_list_by_agent_calls_list_skills_and_returns_catalog_shape():
|
||||
raw = _raw(AgentDriveSkillListByAgentApi.get)
|
||||
with app.test_request_context("/"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
drive.return_value.list_skills.return_value = [
|
||||
{
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
}
|
||||
]
|
||||
body = raw(AgentDriveSkillListByAgentApi(), "tenant-1", "agent-1")
|
||||
|
||||
assert body == {
|
||||
"items": [
|
||||
{
|
||||
"path": "tender-analyzer",
|
||||
"skill_md_key": "tender-analyzer/SKILL.md",
|
||||
"archive_key": "tender-analyzer/.DIFY-SKILL-FULL.zip",
|
||||
"name": "Tender Analyzer",
|
||||
"description": "Parses RFPs.",
|
||||
}
|
||||
]
|
||||
}
|
||||
resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")
|
||||
assert drive.return_value.list_skills.call_args.kwargs == {"tenant_id": "tenant-1", "agent_id": "agent-1"}
|
||||
|
||||
|
||||
def test_list_resolves_workflow_node_binding_agent():
|
||||
raw = _raw(AgentDriveListApi.get)
|
||||
with app.test_request_context("/?node_id=agent-node-1"):
|
||||
@@ -132,6 +101,33 @@ def test_list_resolves_workflow_node_binding_agent():
|
||||
assert composer.resolve_workflow_node_agent_id.call_args.kwargs["node_id"] == "agent-node-1"
|
||||
|
||||
|
||||
def test_skill_list_by_agent_calls_service():
|
||||
raw = _raw(AgentDriveSkillListByAgentApi.get)
|
||||
with app.test_request_context("/"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP) as resolve_app,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
drive.return_value.list_skills.return_value = [
|
||||
{
|
||||
"path": "pdf-toolkit",
|
||||
"skill_md_key": "pdf-toolkit/SKILL.md",
|
||||
"archive_key": "pdf-toolkit/.DIFY-SKILL-FULL.zip",
|
||||
"name": "PDF Toolkit",
|
||||
"description": "Work with PDFs.",
|
||||
"size": 5,
|
||||
"mime_type": "text/markdown",
|
||||
"hash": None,
|
||||
"created_at": 1718000000,
|
||||
}
|
||||
]
|
||||
body = raw(AgentDriveSkillListByAgentApi(), "tenant-1", "agent-1")
|
||||
|
||||
assert body["items"][0]["path"] == "pdf-toolkit"
|
||||
resolve_app.assert_called_once_with(tenant_id="tenant-1", agent_id="agent-1")
|
||||
assert drive.return_value.list_skills.call_args.kwargs["agent_id"] == "agent-1"
|
||||
|
||||
|
||||
def test_skill_list_resolves_workflow_node_binding_agent():
|
||||
raw = _raw(AgentDriveSkillListApi.get)
|
||||
with app.test_request_context("/?node_id=agent-node-1"):
|
||||
@@ -141,10 +137,86 @@ def test_skill_list_resolves_workflow_node_binding_agent():
|
||||
):
|
||||
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
|
||||
drive.return_value.list_skills.return_value = []
|
||||
raw(AgentDriveSkillListApi(), _APP)
|
||||
body = raw(AgentDriveSkillListApi(), _APP)
|
||||
|
||||
assert body == {"items": []}
|
||||
assert drive.return_value.list_skills.call_args.kwargs["agent_id"] == "wf-agent-9"
|
||||
assert composer.resolve_workflow_node_agent_id.call_args.kwargs["node_id"] == "agent-node-1"
|
||||
|
||||
|
||||
def test_skill_inspect_by_agent_returns_strict_json_response():
|
||||
raw = _raw(AgentDriveSkillInspectByAgentApi.get)
|
||||
payload = {
|
||||
"path": "pdf-toolkit",
|
||||
"skill_md_key": "pdf-toolkit/SKILL.md",
|
||||
"archive_key": "pdf-toolkit/.DIFY-SKILL-FULL.zip",
|
||||
"name": "PDF Toolkit",
|
||||
"description": "Work with PDFs.",
|
||||
"size": 5,
|
||||
"mime_type": "text/markdown",
|
||||
"hash": None,
|
||||
"created_at": 1718000000,
|
||||
"source": "skill_md",
|
||||
"files": [
|
||||
{
|
||||
"path": "SKILL.md",
|
||||
"name": "SKILL.md",
|
||||
"type": "file",
|
||||
"drive_key": "pdf-toolkit/SKILL.md",
|
||||
"available_in_drive": True,
|
||||
}
|
||||
],
|
||||
"file_tree": [],
|
||||
"skill_md": {
|
||||
"key": "pdf-toolkit/SKILL.md",
|
||||
"size": 5,
|
||||
"truncated": False,
|
||||
"binary": False,
|
||||
"text": "# PDF Toolkit\nUse it.\n",
|
||||
},
|
||||
"warnings": [],
|
||||
}
|
||||
with app.test_request_context("/"):
|
||||
with (
|
||||
patch(f"{_MOD}.resolve_agent_app_model", return_value=_APP),
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
drive.return_value.inspect_skill.return_value = payload
|
||||
response = raw(AgentDriveSkillInspectByAgentApi(), "tenant-1", "agent-1", "pdf-toolkit")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["skill_md"]["text"] == "# PDF Toolkit\nUse it.\n"
|
||||
assert b"# PDF Toolkit\\nUse it.\\n" in response.get_data()
|
||||
|
||||
|
||||
def test_skill_inspect_resolves_workflow_node_binding_agent():
|
||||
raw = _raw(AgentDriveSkillInspectApi.get)
|
||||
payload = {
|
||||
"path": "pdf-toolkit",
|
||||
"skill_md_key": "pdf-toolkit/SKILL.md",
|
||||
"archive_key": None,
|
||||
"name": "PDF Toolkit",
|
||||
"description": "",
|
||||
"size": 5,
|
||||
"mime_type": "text/markdown",
|
||||
"hash": None,
|
||||
"created_at": None,
|
||||
"source": "skill_md",
|
||||
"files": [],
|
||||
"file_tree": [],
|
||||
"skill_md": {"key": "pdf-toolkit/SKILL.md", "size": 5, "truncated": False, "binary": False, "text": "# hi"},
|
||||
"warnings": [],
|
||||
}
|
||||
with app.test_request_context("/?node_id=agent-node-1"):
|
||||
with (
|
||||
patch(f"{_MOD}.AgentComposerService") as composer,
|
||||
patch(f"{_MOD}.AgentDriveService") as drive,
|
||||
):
|
||||
composer.resolve_workflow_node_agent_id.return_value = "wf-agent-9"
|
||||
drive.return_value.inspect_skill.return_value = payload
|
||||
response = raw(AgentDriveSkillInspectApi(), _APP, "pdf-toolkit")
|
||||
|
||||
assert response.get_json()["path"] == "pdf-toolkit"
|
||||
assert drive.return_value.inspect_skill.call_args.kwargs["agent_id"] == "wf-agent-9"
|
||||
|
||||
|
||||
def test_list_400_when_no_agent_bound():
|
||||
|
||||
@@ -185,7 +185,7 @@ class TestPaginationMapping:
|
||||
"name": "owner",
|
||||
"description": "",
|
||||
"is_builtin": True,
|
||||
"permission_keys": list(rbac_mod._LEGACY_ROLE_PERMISSION_KEYS["owner"]),
|
||||
"permission_keys": list(dict.fromkeys(rbac_mod._LEGACY_ROLE_PERMISSION_KEYS["owner"])),
|
||||
"role_tag": "owner",
|
||||
},
|
||||
{
|
||||
@@ -196,7 +196,7 @@ class TestPaginationMapping:
|
||||
"name": "admin",
|
||||
"description": "",
|
||||
"is_builtin": True,
|
||||
"permission_keys": list(rbac_mod._LEGACY_ROLE_PERMISSION_KEYS["admin"]),
|
||||
"permission_keys": list(dict.fromkeys(rbac_mod._LEGACY_ROLE_PERMISSION_KEYS["admin"])),
|
||||
"role_tag": "",
|
||||
},
|
||||
]
|
||||
@@ -336,23 +336,6 @@ class TestResourceAccessScopeBindings:
|
||||
|
||||
|
||||
class TestPaginationForwarding:
|
||||
def test_role_members_get_forwards_outer_pagination_params(self, app):
|
||||
with (
|
||||
app.test_request_context("/workspaces/current/rbac/roles/role-1/members?page=2&limit=50&reverse=true"),
|
||||
patch("controllers.console.workspace.rbac._current_ids", return_value=("tenant-1", "acct-1")),
|
||||
patch("controllers.console.workspace.rbac.svc.RBACService.Roles.members") as mock_members,
|
||||
patch("controllers.console.workspace.rbac._dump", return_value={}),
|
||||
):
|
||||
inspect.unwrap(rbac_mod.RBACRoleMembersApi.get)(rbac_mod.RBACRoleMembersApi(), "role-1")
|
||||
|
||||
_, _, role_id = mock_members.call_args.args
|
||||
_, kwargs = mock_members.call_args
|
||||
assert role_id == "role-1"
|
||||
options = kwargs["options"]
|
||||
assert options.page_number == 2
|
||||
assert options.results_per_page == 50
|
||||
assert options.reverse is True
|
||||
|
||||
def test_access_policies_get_forwards_outer_pagination_params(self, app):
|
||||
with (
|
||||
app.test_request_context(
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from controllers.openapi._input_schema import EMPTY_INPUT_SCHEMA
|
||||
from controllers.openapi.apps import _EMPTY_PARAMETERS, build_app_describe_response
|
||||
from controllers.service_api.app.error import AppUnavailableError
|
||||
|
||||
|
||||
class _FakeApp(SimpleNamespace):
|
||||
pass
|
||||
|
||||
|
||||
def _app() -> _FakeApp:
|
||||
from datetime import datetime
|
||||
|
||||
return _FakeApp(
|
||||
id="11111111-1111-1111-1111-111111111111",
|
||||
name="Demo",
|
||||
mode="chat",
|
||||
description="d",
|
||||
tags=[],
|
||||
author_name="me",
|
||||
updated_at=datetime(2026, 1, 1),
|
||||
enable_api=True,
|
||||
)
|
||||
|
||||
|
||||
def test_fields_none_returns_all_blocks(monkeypatch):
|
||||
monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {"k": "v"})
|
||||
monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {"s": 1})
|
||||
resp = build_app_describe_response(_app(), None)
|
||||
assert resp.info is not None
|
||||
assert resp.info.name == "Demo"
|
||||
assert resp.parameters == {"k": "v"}
|
||||
assert resp.input_schema == {"s": 1}
|
||||
|
||||
|
||||
def test_fields_subset_limits_blocks(monkeypatch):
|
||||
monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {"k": "v"})
|
||||
monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {"s": 1})
|
||||
resp = build_app_describe_response(_app(), ["info"])
|
||||
assert resp.info is not None
|
||||
assert resp.parameters is None
|
||||
assert resp.input_schema is None
|
||||
|
||||
|
||||
def test_info_omits_author_and_tags(monkeypatch):
|
||||
monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {})
|
||||
monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {})
|
||||
resp = build_app_describe_response(_app(), ["info"])
|
||||
assert resp.info is not None
|
||||
# Usage-face describe must not expose creator identity or tags (cross-tenant leak).
|
||||
assert not hasattr(resp.info, "author")
|
||||
assert not hasattr(resp.info, "tags")
|
||||
|
||||
|
||||
def test_parameters_fallback_on_app_unavailable(monkeypatch):
|
||||
def _raise(app):
|
||||
raise AppUnavailableError()
|
||||
|
||||
monkeypatch.setattr("controllers.openapi.apps.parameters_payload", _raise)
|
||||
monkeypatch.setattr("controllers.openapi.apps.build_input_schema", lambda app: {"s": 1})
|
||||
resp = build_app_describe_response(_app(), ["parameters"])
|
||||
assert resp.parameters == dict(_EMPTY_PARAMETERS)
|
||||
|
||||
|
||||
def test_input_schema_fallback_on_app_unavailable(monkeypatch):
|
||||
def _raise(app):
|
||||
raise AppUnavailableError()
|
||||
|
||||
monkeypatch.setattr("controllers.openapi.apps.parameters_payload", lambda app: {"k": "v"})
|
||||
monkeypatch.setattr("controllers.openapi.apps.build_input_schema", _raise)
|
||||
resp = build_app_describe_response(_app(), ["input_schema"])
|
||||
assert resp.input_schema == dict(EMPTY_INPUT_SCHEMA)
|
||||
@@ -5,7 +5,7 @@ Runs against the model directly, not the HTTP layer. Pins:
|
||||
- workspace_id is required.
|
||||
- numeric bounds enforced (page >= 1, limit in [1, MAX_PAGE_LIMIT]).
|
||||
- mode validates against the AppMode enum.
|
||||
- name and tag have length caps.
|
||||
- name has a length cap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -24,7 +24,6 @@ def test_defaults():
|
||||
assert q.limit == 20
|
||||
assert q.mode is None
|
||||
assert q.name is None
|
||||
assert q.tag is None
|
||||
|
||||
|
||||
def test_workspace_id_required():
|
||||
@@ -80,12 +79,6 @@ def test_name_length_capped():
|
||||
AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "name": "x" * 201})
|
||||
|
||||
|
||||
def test_tag_length_capped():
|
||||
AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "tag": "x" * 100})
|
||||
with pytest.raises(ValidationError):
|
||||
AppListQuery.model_validate({"workspace_id": "00000000-0000-0000-0000-000000000001", "tag": "x" * 101})
|
||||
|
||||
|
||||
def test_all_fields_accept_valid_values():
|
||||
"""Pin the happy-path acceptance for every field in one place."""
|
||||
q = AppListQuery.model_validate(
|
||||
@@ -95,7 +88,6 @@ def test_all_fields_accept_valid_values():
|
||||
"limit": 50,
|
||||
"mode": "workflow",
|
||||
"name": "search",
|
||||
"tag": "prod",
|
||||
}
|
||||
)
|
||||
assert q.workspace_id == "00000000-0000-0000-0000-000000000001"
|
||||
@@ -104,4 +96,3 @@ def test_all_fields_accept_valid_values():
|
||||
assert q.mode is not None
|
||||
assert q.mode.value == "workflow"
|
||||
assert q.name == "search"
|
||||
assert q.tag == "prod"
|
||||
|
||||
@@ -26,11 +26,13 @@ from controllers.openapi._errors import (
|
||||
ErrorBody,
|
||||
ErrorDetail,
|
||||
FilenameNotExists,
|
||||
HumanInputFormNotFound,
|
||||
MemberLicenseExceeded,
|
||||
MemberLimitExceeded,
|
||||
OpenApiError,
|
||||
OpenApiErrorCode,
|
||||
OpenApiErrorFormatter,
|
||||
RecipientSurfaceMismatch,
|
||||
)
|
||||
from controllers.service_api.app.error import (
|
||||
AppUnavailableError,
|
||||
@@ -319,6 +321,8 @@ ERROR_MATRIX = [
|
||||
(BlockedFileExtensionError(), 400, "file_extension_blocked"),
|
||||
(MemberLimitExceeded(), 403, "member_limit_exceeded"),
|
||||
(MemberLicenseExceeded(), 403, "member_license_exceeded"),
|
||||
(HumanInputFormNotFound(), 404, "form_not_found"),
|
||||
(RecipientSurfaceMismatch(), 403, "recipient_surface_mismatch"),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -11,8 +11,9 @@ from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
from flask import Flask
|
||||
from werkzeug.exceptions import NotFound, UnprocessableEntity
|
||||
from werkzeug.exceptions import UnprocessableEntity
|
||||
|
||||
from controllers.openapi._errors import HumanInputFormNotFound, RecipientSurfaceMismatch
|
||||
from controllers.openapi.auth.data import AuthData
|
||||
from libs.oauth_bearer import Scope, TokenType
|
||||
from models.human_input import RecipientType
|
||||
@@ -89,7 +90,7 @@ class TestOpenApiHumanInputFormGet:
|
||||
caller = SimpleNamespace(id="acct-1")
|
||||
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/bad"):
|
||||
with pytest.raises(NotFound):
|
||||
with pytest.raises(HumanInputFormNotFound):
|
||||
api.get.__wrapped__(
|
||||
api,
|
||||
app_id="app-1",
|
||||
@@ -101,7 +102,10 @@ class TestOpenApiHumanInputFormGet:
|
||||
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi
|
||||
|
||||
form = SimpleNamespace(
|
||||
app_id="other-app", tenant_id="tenant-1", expiration_time=datetime(2099, 1, 1, tzinfo=UTC)
|
||||
app_id="other-app",
|
||||
tenant_id="tenant-1",
|
||||
recipient_type=RecipientType.STANDALONE_WEB_APP,
|
||||
expiration_time=datetime(2099, 1, 1, tzinfo=UTC),
|
||||
)
|
||||
service_mock = Mock()
|
||||
service_mock.get_form_by_token.return_value = form
|
||||
@@ -114,7 +118,7 @@ class TestOpenApiHumanInputFormGet:
|
||||
caller = SimpleNamespace(id="acct-1")
|
||||
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/tok-1"):
|
||||
with pytest.raises(NotFound):
|
||||
with pytest.raises(HumanInputFormNotFound):
|
||||
api.get.__wrapped__(
|
||||
api,
|
||||
app_id="app-1",
|
||||
@@ -142,7 +146,7 @@ class TestOpenApiHumanInputFormGet:
|
||||
caller = SimpleNamespace(id="acct-1")
|
||||
|
||||
with app.test_request_context("/openapi/v1/apps/app-1/form/human_input/tok-1"):
|
||||
with pytest.raises(NotFound):
|
||||
with pytest.raises(RecipientSurfaceMismatch):
|
||||
api.get.__wrapped__(
|
||||
api,
|
||||
app_id="app-1",
|
||||
@@ -234,6 +238,38 @@ class TestOpenApiHumanInputFormPost:
|
||||
)
|
||||
assert result == ({}, 200)
|
||||
|
||||
def test_post_standalone_web_app_recipient_submits(
|
||||
self, app: Flask, bypass_pipeline, monkeypatch: pytest.MonkeyPatch
|
||||
):
|
||||
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi
|
||||
|
||||
form = self._make_form(recipient_type=RecipientType.STANDALONE_WEB_APP)
|
||||
service_mock = Mock()
|
||||
service_mock.get_form_by_token.return_value = form
|
||||
|
||||
module = sys.modules["controllers.openapi.human_input_form"]
|
||||
monkeypatch.setattr(module, "HumanInputService", lambda _engine: service_mock)
|
||||
monkeypatch.setattr(module, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
api = OpenApiWorkflowHumanInputFormApi()
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1")
|
||||
caller = SimpleNamespace(id="anyone")
|
||||
|
||||
with app.test_request_context(
|
||||
"/openapi/v1/apps/app-1/form/human_input/tok-1",
|
||||
method="POST",
|
||||
json={"action": "approve", "inputs": {}},
|
||||
):
|
||||
result = api.post.__wrapped__(
|
||||
api,
|
||||
app_id="app-1",
|
||||
form_token="tok-1",
|
||||
auth_data=_make_auth_data(app_model, caller, "end_user"),
|
||||
)
|
||||
|
||||
service_mock.submit_form_by_token.assert_called_once()
|
||||
assert result == ({}, 200)
|
||||
|
||||
def test_post_rejects_invalid_body_with_422(self, app: Flask, bypass_pipeline):
|
||||
"""Malformed body → 422 via @accepts (was an unmapped pydantic error → 500)."""
|
||||
from controllers.openapi.human_input_form import OpenApiWorkflowHumanInputFormApi
|
||||
|
||||
@@ -63,23 +63,19 @@ def test_envelope_uses_pep695_generics():
|
||||
|
||||
|
||||
def test_app_info_response_dump_matches_spec():
|
||||
from controllers.openapi._models import AppInfoResponse
|
||||
from controllers.openapi._models import AppInfo
|
||||
|
||||
obj = AppInfoResponse(
|
||||
obj = AppInfo(
|
||||
id="app1",
|
||||
name="X",
|
||||
description="d",
|
||||
mode="chat",
|
||||
author="alice",
|
||||
tags=[{"name": "prod"}],
|
||||
)
|
||||
assert obj.model_dump(mode="json") == {
|
||||
"id": "app1",
|
||||
"name": "X",
|
||||
"description": "d",
|
||||
"mode": "chat",
|
||||
"author": "alice",
|
||||
"tags": [{"name": "prod"}],
|
||||
}
|
||||
|
||||
|
||||
@@ -91,8 +87,6 @@ def test_app_describe_response_nests_info_and_parameters():
|
||||
name="X",
|
||||
mode="chat",
|
||||
description=None,
|
||||
tags=[],
|
||||
author=None,
|
||||
updated_at="2026-05-05T00:00:00+00:00",
|
||||
service_api_enabled=True,
|
||||
)
|
||||
|
||||
@@ -29,7 +29,7 @@ from core.app.entities.task_entities import (
|
||||
WorkflowPauseStreamResponse,
|
||||
)
|
||||
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper
|
||||
from core.workflow.human_input_policy import HumanInputSurface
|
||||
from core.workflow.human_input_policy import FormDisposition, HumanInputSurface
|
||||
from core.workflow.system_variables import build_system_variables
|
||||
from graphon.entities import WorkflowStartReason
|
||||
from graphon.entities.pause_reason import HumanInputRequired, PauseReasonType
|
||||
@@ -592,8 +592,10 @@ class TestHitlServiceApi:
|
||||
monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object()))
|
||||
monkeypatch.setattr(
|
||||
workflow_response_converter,
|
||||
"load_form_tokens_by_form_id",
|
||||
lambda form_ids, session=None, surface=None: {"form-1": "token"},
|
||||
"load_form_dispositions_by_form_id",
|
||||
lambda form_ids, session=None, surface=None: {
|
||||
"form-1": FormDisposition(form_token="token", approval_channels=[])
|
||||
},
|
||||
)
|
||||
|
||||
reason = HumanInputRequired(
|
||||
@@ -652,8 +654,10 @@ class TestHitlServiceApi:
|
||||
snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED)
|
||||
resumption_context = _build_resumption_context("task-ctx")
|
||||
monkeypatch.setattr(
|
||||
"services.workflow_event_snapshot_service.load_form_tokens_by_form_id",
|
||||
lambda form_ids, session=None, surface=None: {"form-1": "wtok"},
|
||||
"services.workflow_event_snapshot_service.load_form_dispositions_by_form_id",
|
||||
lambda form_ids, session=None, surface=None: {
|
||||
"form-1": FormDisposition(form_token="wtok", approval_channels=[])
|
||||
},
|
||||
)
|
||||
|
||||
class _SessionContext:
|
||||
|
||||
@@ -175,6 +175,7 @@ class TestAdvancedChatGenerateTaskPipeline:
|
||||
"actions": [{"id": "approve", "title": "Approve", "button_style": "default"}],
|
||||
"display_in_ui": True,
|
||||
"form_token": "token-1",
|
||||
"approval_channels": [],
|
||||
"resolved_default_values": {},
|
||||
"expiration_time": 123,
|
||||
}
|
||||
|
||||
@@ -26,6 +26,26 @@ from models.account import Account
|
||||
from models.human_input import RecipientType
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
"""Stub session: `execute` feeds the form-expiration query, `scalars` the recipients."""
|
||||
|
||||
def __init__(self, *, execute_rows=(), scalars_rows=()):
|
||||
self._execute_rows = execute_rows
|
||||
self._scalars_rows = scalars_rows
|
||||
|
||||
def execute(self, _stmt):
|
||||
return list(self._execute_rows)
|
||||
|
||||
def scalars(self, _stmt):
|
||||
return list(self._scalars_rows)
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
|
||||
class _RecordingWorkflowAppRunner(WorkflowAppRunner):
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
@@ -97,11 +117,11 @@ def test_graph_run_paused_event_emits_queue_pause_event():
|
||||
assert queue_event.paused_nodes == ["node-pause-1"]
|
||||
|
||||
|
||||
def _build_converter():
|
||||
def _build_converter(*, invoke_from: InvokeFrom = InvokeFrom.SERVICE_API):
|
||||
application_generate_entity = SimpleNamespace(
|
||||
inputs={},
|
||||
files=[],
|
||||
invoke_from=InvokeFrom.SERVICE_API,
|
||||
invoke_from=invoke_from,
|
||||
app_config=SimpleNamespace(app_id="app-id", tenant_id="tenant-id"),
|
||||
)
|
||||
system_variables = build_system_variables(
|
||||
@@ -131,32 +151,15 @@ def test_queue_workflow_paused_event_to_stream_responses(monkeypatch: pytest.Mon
|
||||
)
|
||||
|
||||
expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
session = _FakeSession(
|
||||
execute_rows=[("form-1", expiration_time, '{"display_in_ui": true}')],
|
||||
scalars_rows=[
|
||||
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.CONSOLE, access_token="console-token"),
|
||||
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"),
|
||||
],
|
||||
)
|
||||
|
||||
class _FakeSession:
|
||||
def execute(self, _stmt):
|
||||
return [("form-1", expiration_time, '{"display_in_ui": true}')]
|
||||
|
||||
def scalars(self, _stmt):
|
||||
return [
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.CONSOLE,
|
||||
access_token="console-token",
|
||||
),
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.BACKSTAGE,
|
||||
access_token="backstage-token",
|
||||
),
|
||||
]
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: _FakeSession())
|
||||
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: session)
|
||||
monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
reason = HumanInputRequired(
|
||||
@@ -195,10 +198,92 @@ def test_queue_workflow_paused_event_to_stream_responses(monkeypatch: pytest.Mon
|
||||
assert hi_resp.data.inputs[0].output_variable_name == "field"
|
||||
assert hi_resp.data.actions[0].id == "approve"
|
||||
assert hi_resp.data.display_in_ui is True
|
||||
assert hi_resp.data.form_token == "backstage-token"
|
||||
assert hi_resp.data.form_token is None
|
||||
assert hi_resp.data.approval_channels == ["console"]
|
||||
assert hi_resp.data.expiration_time == int(expiration_time.timestamp())
|
||||
|
||||
|
||||
def _build_paused_human_input_response(monkeypatch, recipients):
|
||||
"""Drive the live OPENAPI pause path with the given recipients via a fake session."""
|
||||
converter = _build_converter(invoke_from=InvokeFrom.OPENAPI)
|
||||
converter.workflow_start_to_stream_response(
|
||||
task_id="task",
|
||||
workflow_run_id="run-id",
|
||||
workflow_id="workflow-id",
|
||||
reason=WorkflowStartReason.INITIAL,
|
||||
)
|
||||
|
||||
expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
session = _FakeSession(
|
||||
execute_rows=[("form-1", expiration_time, '{"display_in_ui": true}')],
|
||||
scalars_rows=list(recipients),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: session)
|
||||
monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
reason = HumanInputRequired(
|
||||
form_id="form-1",
|
||||
form_content="Rendered",
|
||||
inputs=[ParagraphInputConfig(output_variable_name="field")],
|
||||
actions=[UserActionConfig(id="approve", title="Approve")],
|
||||
node_id="node-id",
|
||||
node_title="Human Step",
|
||||
)
|
||||
queue_event = QueueWorkflowPausedEvent(
|
||||
reasons=[reason],
|
||||
outputs={},
|
||||
paused_nodes=["node-id"],
|
||||
)
|
||||
|
||||
runtime_state = GraphRuntimeState(variable_pool=VariablePool(), start_at=0.0)
|
||||
responses = converter.workflow_pause_to_stream_response(
|
||||
event=queue_event,
|
||||
task_id="task",
|
||||
graph_runtime_state=runtime_state,
|
||||
)
|
||||
assert isinstance(responses[0], HumanInputRequiredResponse)
|
||||
return responses
|
||||
|
||||
|
||||
def test_openapi_pause_without_web_app_recipient_emits_approval_channels(monkeypatch: pytest.MonkeyPatch):
|
||||
responses = _build_paused_human_input_response(
|
||||
monkeypatch,
|
||||
recipients=[
|
||||
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.EMAIL_MEMBER, access_token="email-token"),
|
||||
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"),
|
||||
],
|
||||
)
|
||||
|
||||
hi_resp = responses[0]
|
||||
assert hi_resp.data.form_token is None
|
||||
assert hi_resp.data.approval_channels == ["console", "email"]
|
||||
|
||||
pause_resp = responses[-1]
|
||||
assert pause_resp.data.reasons[0]["approval_channels"] == ["console", "email"]
|
||||
|
||||
|
||||
def test_openapi_pause_with_web_app_recipient_sets_token_and_channels(monkeypatch: pytest.MonkeyPatch):
|
||||
responses = _build_paused_human_input_response(
|
||||
monkeypatch,
|
||||
recipients=[
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.STANDALONE_WEB_APP,
|
||||
access_token="web-app-token",
|
||||
),
|
||||
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"),
|
||||
],
|
||||
)
|
||||
|
||||
hi_resp = responses[0]
|
||||
assert hi_resp.data.form_token == "web-app-token"
|
||||
assert hi_resp.data.approval_channels == ["console"]
|
||||
|
||||
pause_resp = responses[-1]
|
||||
assert pause_resp.data.reasons[0]["approval_channels"] == ["console"]
|
||||
|
||||
|
||||
def test_queue_workflow_paused_event_resolves_variable_select_options(monkeypatch: pytest.MonkeyPatch):
|
||||
converter = _build_converter()
|
||||
converter.workflow_start_to_stream_response(
|
||||
@@ -209,21 +294,9 @@ def test_queue_workflow_paused_event_resolves_variable_select_options(monkeypatc
|
||||
)
|
||||
|
||||
expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
session = _FakeSession(execute_rows=[("form-1", expiration_time, '{"display_in_ui": true}')])
|
||||
|
||||
class _FakeSession:
|
||||
def execute(self, _stmt):
|
||||
return [("form-1", expiration_time, '{"display_in_ui": true}')]
|
||||
|
||||
def scalars(self, _stmt):
|
||||
return []
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: _FakeSession())
|
||||
monkeypatch.setattr(workflow_response_converter, "Session", lambda **_: session)
|
||||
monkeypatch.setattr(workflow_response_converter, "db", SimpleNamespace(engine=object()))
|
||||
|
||||
reason = HumanInputRequired(
|
||||
|
||||
@@ -134,6 +134,7 @@ class TestWorkflowGenerateTaskPipeline:
|
||||
"actions": [],
|
||||
"display_in_ui": False,
|
||||
"form_token": None,
|
||||
"approval_channels": [],
|
||||
"resolved_default_values": {},
|
||||
"expiration_time": 1,
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ from models.human_input import (
|
||||
EmailMemberRecipientPayload,
|
||||
HumanInputFormRecipient,
|
||||
RecipientType,
|
||||
StandaloneWebAppRecipientPayload,
|
||||
)
|
||||
|
||||
|
||||
@@ -307,6 +308,9 @@ class _DummyRecipient:
|
||||
recipient_type: RecipientType
|
||||
access_token: str
|
||||
form: _DummyForm | None = None
|
||||
recipient_payload: str = dataclasses.field(
|
||||
default_factory=lambda: StandaloneWebAppRecipientPayload().model_dump_json()
|
||||
)
|
||||
|
||||
|
||||
class _FakeScalarResult:
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import pytest
|
||||
|
||||
from core.workflow.human_input_policy import FormDisposition, enrich_human_input_pause_reasons
|
||||
from graphon.entities.pause_reason import PauseReasonType
|
||||
|
||||
_HUMAN_INPUT_REASON = {"TYPE": PauseReasonType.HUMAN_INPUT_REQUIRED, "form_id": "f1"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("dispositions", "expected_token", "expected_channels"),
|
||||
[
|
||||
({"f1": FormDisposition(form_token=None, approval_channels=["console", "email"])}, None, ["console", "email"]),
|
||||
({"f1": FormDisposition(form_token="tok", approval_channels=[])}, "tok", []),
|
||||
# form_id absent from the map (no recipient rows) falls back to no token, no channels.
|
||||
({}, None, []),
|
||||
],
|
||||
)
|
||||
def test_enrich_projects_disposition_onto_reason(dispositions, expected_token, expected_channels):
|
||||
out = enrich_human_input_pause_reasons(
|
||||
[dict(_HUMAN_INPUT_REASON)],
|
||||
dispositions_by_form_id=dispositions,
|
||||
expiration_times_by_form_id={},
|
||||
)
|
||||
|
||||
assert out[0]["form_token"] == expected_token
|
||||
assert out[0]["approval_channels"] == expected_channels
|
||||
|
||||
|
||||
def test_enrich_leaves_non_human_input_reasons_untouched():
|
||||
reason = {"TYPE": "something_else", "form_id": "f1"}
|
||||
|
||||
out = enrich_human_input_pause_reasons(
|
||||
[reason],
|
||||
dispositions_by_form_id={"f1": FormDisposition(form_token="tok", approval_channels=["email"])},
|
||||
expiration_times_by_form_id={},
|
||||
)
|
||||
|
||||
assert out[0] == reason
|
||||
assert "form_token" not in out[0]
|
||||
assert "approval_channels" not in out[0]
|
||||
|
||||
|
||||
def test_pause_reason_payload_carries_approval_channels_through_factory():
|
||||
# from_response_data maps fields by hand; this guards approval_channels/form_token
|
||||
# (the fields this feature added) against being dropped in that mapping.
|
||||
from core.app.entities.task_entities import (
|
||||
HumanInputRequiredPauseReasonPayload,
|
||||
HumanInputRequiredResponse,
|
||||
)
|
||||
|
||||
data = HumanInputRequiredResponse.Data(
|
||||
form_id="f",
|
||||
node_id="n",
|
||||
node_title="t",
|
||||
form_content="c",
|
||||
expiration_time=123,
|
||||
form_token=None,
|
||||
approval_channels=["console"],
|
||||
)
|
||||
payload = HumanInputRequiredPauseReasonPayload.from_response_data(data)
|
||||
|
||||
assert payload.approval_channels == ["console"]
|
||||
assert payload.form_token is None
|
||||
@@ -1,7 +1,16 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from core.workflow.human_input_forms import _load_form_tokens_by_form_id, load_form_tokens_by_form_id
|
||||
from core.workflow.human_input_policy import HumanInputSurface
|
||||
import pytest
|
||||
|
||||
from core.workflow.human_input_forms import (
|
||||
load_form_dispositions_by_form_id,
|
||||
load_form_tokens_by_form_id,
|
||||
)
|
||||
from core.workflow.human_input_policy import (
|
||||
FormDisposition,
|
||||
HumanInputSurface,
|
||||
disposition_for_surface,
|
||||
)
|
||||
from models.human_input import RecipientType
|
||||
|
||||
|
||||
@@ -13,91 +22,100 @@ class _FakeSession:
|
||||
return self._recipients
|
||||
|
||||
|
||||
def test_load_form_tokens_by_form_id_prefers_backstage_token() -> None:
|
||||
def _recipient(form_id: str, recipient_type: RecipientType, access_token: str | None) -> SimpleNamespace:
|
||||
return SimpleNamespace(form_id=form_id, recipient_type=recipient_type, access_token=access_token)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("surface", "expected_token"),
|
||||
[
|
||||
# Unfiltered (no surface) picks the highest-priority recipient: backstage.
|
||||
(None, "backstage-token"),
|
||||
# SERVICE_API may only act on the web-app recipient.
|
||||
(HumanInputSurface.SERVICE_API, "web-token"),
|
||||
],
|
||||
)
|
||||
def test_load_form_tokens_picks_token_for_surface(surface, expected_token) -> None:
|
||||
session = _FakeSession(
|
||||
recipients=[
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.STANDALONE_WEB_APP,
|
||||
access_token="web-token",
|
||||
),
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.CONSOLE,
|
||||
access_token="console-token",
|
||||
),
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.BACKSTAGE,
|
||||
access_token="backstage-token",
|
||||
),
|
||||
[
|
||||
_recipient("form-1", RecipientType.STANDALONE_WEB_APP, "web-token"),
|
||||
_recipient("form-1", RecipientType.CONSOLE, "console-token"),
|
||||
_recipient("form-1", RecipientType.BACKSTAGE, "backstage-token"),
|
||||
]
|
||||
)
|
||||
|
||||
assert load_form_tokens_by_form_id(["form-1"], session=session) == {"form-1": "backstage-token"}
|
||||
assert load_form_tokens_by_form_id(["form-1"], session=session, surface=surface) == {"form-1": expected_token}
|
||||
|
||||
|
||||
def test_load_form_tokens_by_form_id_ignores_unsupported_recipients() -> None:
|
||||
def test_load_form_tokens_drops_forms_without_actionable_token() -> None:
|
||||
session = _FakeSession(
|
||||
recipients=[
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.EMAIL_MEMBER,
|
||||
access_token="email-token",
|
||||
),
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.CONSOLE,
|
||||
access_token=None,
|
||||
),
|
||||
[
|
||||
_recipient("form-1", RecipientType.EMAIL_MEMBER, "email-token"),
|
||||
_recipient("form-1", RecipientType.CONSOLE, None),
|
||||
]
|
||||
)
|
||||
|
||||
assert load_form_tokens_by_form_id(["form-1"], session=session) == {}
|
||||
|
||||
|
||||
def test_load_form_tokens_by_form_id_uses_shared_priority() -> None:
|
||||
def test_load_form_tokens_service_api_surface_uses_web_token() -> None:
|
||||
session = _FakeSession(
|
||||
recipients=[
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.STANDALONE_WEB_APP,
|
||||
access_token="web-token",
|
||||
),
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.CONSOLE,
|
||||
access_token="console-token",
|
||||
),
|
||||
[
|
||||
_recipient("form-1", RecipientType.STANDALONE_WEB_APP, "web-token"),
|
||||
_recipient("form-1", RecipientType.CONSOLE, "console-token"),
|
||||
_recipient("form-1", RecipientType.BACKSTAGE, "backstage-token"),
|
||||
]
|
||||
)
|
||||
|
||||
assert _load_form_tokens_by_form_id(session, ["form-1"]) == {"form-1": "console-token"}
|
||||
assert load_form_tokens_by_form_id(["form-1"], session=session, surface=HumanInputSurface.SERVICE_API) == {
|
||||
"form-1": "web-token"
|
||||
}
|
||||
|
||||
|
||||
def test_load_form_tokens_by_form_id_uses_web_token_for_service_api_surface() -> None:
|
||||
def test_load_dispositions_openapi_webapp_form_is_resumable() -> None:
|
||||
session = _FakeSession(
|
||||
recipients=[
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.STANDALONE_WEB_APP,
|
||||
access_token="web-token",
|
||||
),
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.CONSOLE,
|
||||
access_token="console-token",
|
||||
),
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.BACKSTAGE,
|
||||
access_token="backstage-token",
|
||||
),
|
||||
[
|
||||
_recipient("form-1", RecipientType.STANDALONE_WEB_APP, "web-token"),
|
||||
_recipient("form-1", RecipientType.BACKSTAGE, "backstage-token"),
|
||||
]
|
||||
)
|
||||
|
||||
assert load_form_tokens_by_form_id(
|
||||
["form-1"],
|
||||
session=session,
|
||||
surface=HumanInputSurface.SERVICE_API,
|
||||
) == {"form-1": "web-token"}
|
||||
assert load_form_dispositions_by_form_id(["form-1"], session=session, surface=HumanInputSurface.OPENAPI) == {
|
||||
"form-1": FormDisposition(form_token="web-token", approval_channels=["console"])
|
||||
}
|
||||
|
||||
|
||||
def test_load_dispositions_openapi_backstage_only_form_yields_channels_not_token() -> None:
|
||||
session = _FakeSession([_recipient("form-1", RecipientType.BACKSTAGE, "backstage-token")])
|
||||
|
||||
assert load_form_dispositions_by_form_id(["form-1"], session=session, surface=HumanInputSurface.OPENAPI) == {
|
||||
"form-1": FormDisposition(form_token=None, approval_channels=["console"])
|
||||
}
|
||||
|
||||
|
||||
# disposition_for_surface partitions recipients into a surface-actionable resume
|
||||
# token plus the approval channels of the recipients the surface may NOT act on.
|
||||
_WEB = (RecipientType.STANDALONE_WEB_APP, "tok_web")
|
||||
_BACKSTAGE = (RecipientType.BACKSTAGE, "tok_b")
|
||||
_CONSOLE = (RecipientType.CONSOLE, "tok_c")
|
||||
_EMAIL_MEMBER = (RecipientType.EMAIL_MEMBER, "t1")
|
||||
_EMAIL_EXTERNAL = (RecipientType.EMAIL_EXTERNAL, "t2")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("recipients", "surface", "expected"),
|
||||
[
|
||||
# Token surface acts on the web-app recipient; blocked recipients become channels.
|
||||
([_BACKSTAGE, _WEB], HumanInputSurface.OPENAPI, FormDisposition("tok_web", ["console"])),
|
||||
([_EMAIL_MEMBER, _EMAIL_EXTERNAL], HumanInputSurface.OPENAPI, FormDisposition(None, ["email"])),
|
||||
([_EMAIL_MEMBER, _BACKSTAGE], HumanInputSurface.OPENAPI, FormDisposition(None, ["console", "email"])),
|
||||
# CONSOLE acts on console/backstage; a web-app recipient is blocked → web_app channel.
|
||||
([_CONSOLE, _WEB], HumanInputSurface.CONSOLE, FormDisposition("tok_c", ["web_app"])),
|
||||
([_WEB], HumanInputSurface.CONSOLE, FormDisposition(None, ["web_app"])),
|
||||
# No surface: unfiltered priority token, channels never populated.
|
||||
([_BACKSTAGE], None, FormDisposition("tok_b", [])),
|
||||
([_WEB, _EMAIL_MEMBER], None, FormDisposition("tok_web", [])),
|
||||
],
|
||||
)
|
||||
def test_disposition_for_surface_partitions_token_and_channels(recipients, surface, expected) -> None:
|
||||
assert disposition_for_surface(recipients, surface=surface) == expected
|
||||
|
||||
@@ -12,38 +12,28 @@ from graphon.runtime import VariablePool
|
||||
from models.human_input import RecipientType
|
||||
|
||||
|
||||
def test_service_api_only_allows_public_webapp_forms() -> None:
|
||||
assert is_recipient_type_allowed_for_surface(
|
||||
RecipientType.STANDALONE_WEB_APP,
|
||||
HumanInputSurface.SERVICE_API,
|
||||
)
|
||||
assert not is_recipient_type_allowed_for_surface(
|
||||
RecipientType.CONSOLE,
|
||||
HumanInputSurface.SERVICE_API,
|
||||
)
|
||||
assert not is_recipient_type_allowed_for_surface(
|
||||
RecipientType.BACKSTAGE,
|
||||
HumanInputSurface.SERVICE_API,
|
||||
)
|
||||
assert not is_recipient_type_allowed_for_surface(
|
||||
RecipientType.EMAIL_MEMBER,
|
||||
HumanInputSurface.SERVICE_API,
|
||||
)
|
||||
|
||||
|
||||
def test_console_only_allows_internal_console_surfaces() -> None:
|
||||
assert is_recipient_type_allowed_for_surface(
|
||||
RecipientType.CONSOLE,
|
||||
HumanInputSurface.CONSOLE,
|
||||
)
|
||||
assert is_recipient_type_allowed_for_surface(
|
||||
RecipientType.BACKSTAGE,
|
||||
HumanInputSurface.CONSOLE,
|
||||
)
|
||||
assert not is_recipient_type_allowed_for_surface(
|
||||
RecipientType.STANDALONE_WEB_APP,
|
||||
HumanInputSurface.CONSOLE,
|
||||
)
|
||||
# Token surfaces (SERVICE_API, OPENAPI) may act only on public web-app forms;
|
||||
# CONSOLE may act on internal console/backstage forms. OPENAPI mirrors SERVICE_API
|
||||
# today but is pinned independently because the two are expected to diverge.
|
||||
@pytest.mark.parametrize(
|
||||
("recipient_type", "surface", "allowed"),
|
||||
[
|
||||
(RecipientType.STANDALONE_WEB_APP, HumanInputSurface.SERVICE_API, True),
|
||||
(RecipientType.CONSOLE, HumanInputSurface.SERVICE_API, False),
|
||||
(RecipientType.BACKSTAGE, HumanInputSurface.SERVICE_API, False),
|
||||
(RecipientType.EMAIL_MEMBER, HumanInputSurface.SERVICE_API, False),
|
||||
(RecipientType.STANDALONE_WEB_APP, HumanInputSurface.OPENAPI, True),
|
||||
(RecipientType.CONSOLE, HumanInputSurface.OPENAPI, False),
|
||||
(RecipientType.BACKSTAGE, HumanInputSurface.OPENAPI, False),
|
||||
(RecipientType.CONSOLE, HumanInputSurface.CONSOLE, True),
|
||||
(RecipientType.BACKSTAGE, HumanInputSurface.CONSOLE, True),
|
||||
(RecipientType.STANDALONE_WEB_APP, HumanInputSurface.CONSOLE, False),
|
||||
],
|
||||
)
|
||||
def test_recipient_type_allowed_per_surface(
|
||||
recipient_type: RecipientType, surface: HumanInputSurface, allowed: bool
|
||||
) -> None:
|
||||
assert is_recipient_type_allowed_for_surface(recipient_type, surface) is allowed
|
||||
|
||||
|
||||
def test_preferred_form_token_uses_shared_priority_order() -> None:
|
||||
@@ -56,6 +46,17 @@ def test_preferred_form_token_uses_shared_priority_order() -> None:
|
||||
assert get_preferred_form_token(recipients) == "backstage-token"
|
||||
|
||||
|
||||
def test_preferred_form_token_skips_prioritized_type_with_empty_token() -> None:
|
||||
# An empty token is not actionable: the highest-priority recipient that
|
||||
# actually carries a token wins, not the highest-priority type.
|
||||
recipients = [
|
||||
(RecipientType.BACKSTAGE, ""),
|
||||
(RecipientType.CONSOLE, "console-token"),
|
||||
]
|
||||
|
||||
assert get_preferred_form_token(recipients) == "console-token"
|
||||
|
||||
|
||||
def test_resolve_variable_select_input_options_uses_runtime_values() -> None:
|
||||
variable_pool = VariablePool()
|
||||
variable_pool.add(("start", "options"), ["approve", "reject"])
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
"""Tests for OPENAPI surface in HumanInputPolicy and human_input_forms."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.workflow.human_input_policy import HumanInputSurface, is_recipient_type_allowed_for_surface
|
||||
from models.human_input import RecipientType
|
||||
|
||||
|
||||
def test_openapi_surface_exists():
|
||||
assert HumanInputSurface.OPENAPI == "openapi"
|
||||
|
||||
|
||||
def test_openapi_allows_standalone_web_app():
|
||||
assert is_recipient_type_allowed_for_surface(RecipientType.STANDALONE_WEB_APP, HumanInputSurface.OPENAPI)
|
||||
|
||||
|
||||
def test_openapi_rejects_console_recipient():
|
||||
assert not is_recipient_type_allowed_for_surface(RecipientType.CONSOLE, HumanInputSurface.OPENAPI)
|
||||
|
||||
|
||||
def test_openapi_rejects_backstage_recipient():
|
||||
assert not is_recipient_type_allowed_for_surface(RecipientType.BACKSTAGE, HumanInputSurface.OPENAPI)
|
||||
|
||||
|
||||
def test_get_surface_form_token_openapi_picks_standalone_web_app():
|
||||
"""OPENAPI surface should pick STANDALONE_WEB_APP token, same as SERVICE_API."""
|
||||
from core.workflow.human_input_forms import _get_surface_form_token
|
||||
|
||||
recipients = [
|
||||
(RecipientType.BACKSTAGE, "backstage-token"),
|
||||
(RecipientType.STANDALONE_WEB_APP, "web-token"),
|
||||
]
|
||||
token = _get_surface_form_token(recipients, surface=HumanInputSurface.OPENAPI)
|
||||
assert token == "web-token"
|
||||
@@ -33,6 +33,7 @@ def test_agent_enums_match_prd_boundaries():
|
||||
assert AgentStatus.ACTIVE.value == "active"
|
||||
assert AgentStatus.ARCHIVED.value == "archived"
|
||||
assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION.value == "save_current_version"
|
||||
assert AgentConfigRevisionOperation.RESTORE_VERSION.value == "restore_version"
|
||||
assert WorkflowAgentBindingType.ROSTER_AGENT.value == "roster_agent"
|
||||
assert WorkflowAgentBindingType.INLINE_AGENT.value == "inline_agent"
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import pytest
|
||||
|
||||
from models.human_input import ApprovalChannel, RecipientType
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("recipient_type", "expected_channel"),
|
||||
[
|
||||
(RecipientType.EMAIL_MEMBER, ApprovalChannel.EMAIL),
|
||||
(RecipientType.EMAIL_EXTERNAL, ApprovalChannel.EMAIL),
|
||||
(RecipientType.CONSOLE, ApprovalChannel.CONSOLE),
|
||||
(RecipientType.BACKSTAGE, ApprovalChannel.CONSOLE),
|
||||
(RecipientType.STANDALONE_WEB_APP, ApprovalChannel.WEB_APP),
|
||||
],
|
||||
)
|
||||
def test_approval_channel_collapses_delivery_types(
|
||||
recipient_type: RecipientType, expected_channel: ApprovalChannel
|
||||
) -> None:
|
||||
# Both email types collapse to EMAIL and console/backstage to CONSOLE:
|
||||
# the user-facing approval channel, not the internal recipient type.
|
||||
assert recipient_type.approval_channel == expected_channel
|
||||
@@ -1045,12 +1045,93 @@ def test_agent_app_visible_versions_exclude_draft_saves():
|
||||
agent_app_operations = AgentRosterService._visible_version_operations(agent_app)
|
||||
roster_operations = AgentRosterService._visible_version_operations(roster_agent)
|
||||
|
||||
assert agent_app_operations == {AgentConfigRevisionOperation.SAVE_NEW_VERSION}
|
||||
assert agent_app_operations == {
|
||||
AgentConfigRevisionOperation.SAVE_NEW_VERSION,
|
||||
AgentConfigRevisionOperation.RESTORE_VERSION,
|
||||
}
|
||||
assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION not in agent_app_operations
|
||||
assert AgentConfigRevisionOperation.CREATE_VERSION in roster_operations
|
||||
assert AgentConfigRevisionOperation.RESTORE_VERSION in roster_operations
|
||||
assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION not in roster_operations
|
||||
|
||||
|
||||
def test_restore_roster_agent_version_switches_active_snapshot(monkeypatch: pytest.MonkeyPatch):
|
||||
fake_session = FakeSession(scalar=["version-2", 6])
|
||||
service = AgentRosterService(fake_session)
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
tenant_id="tenant-1",
|
||||
name="Analyst",
|
||||
description="old",
|
||||
agent_kind=AgentKind.DIFY_AGENT,
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
status=AgentStatus.ACTIVE,
|
||||
active_config_snapshot_id="version-4",
|
||||
)
|
||||
version = AgentConfigSnapshot(
|
||||
id="version-2",
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
version=2,
|
||||
config_snapshot=_agent_soul_with_model(),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(service, "_get_agent", lambda **kwargs: agent)
|
||||
monkeypatch.setattr(service, "_get_version", lambda **kwargs: version)
|
||||
|
||||
restored = service.restore_agent_version(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
version_id="version-2",
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
assert restored == {"result": "success", "active_config_snapshot_id": "version-2"}
|
||||
assert agent.active_config_snapshot_id == "version-2"
|
||||
assert agent.active_config_has_model is True
|
||||
assert agent.updated_by == "account-1"
|
||||
assert fake_session.commits == 1
|
||||
revision = fake_session.added[0]
|
||||
assert revision.tenant_id == "tenant-1"
|
||||
assert revision.agent_id == "agent-1"
|
||||
assert revision.previous_snapshot_id == "version-4"
|
||||
assert revision.current_snapshot_id == "version-2"
|
||||
assert revision.revision == 7
|
||||
assert revision.operation == AgentConfigRevisionOperation.RESTORE_VERSION
|
||||
assert revision.created_by == "account-1"
|
||||
|
||||
|
||||
def test_restore_roster_agent_version_rejects_invisible_versions(monkeypatch: pytest.MonkeyPatch):
|
||||
fake_session = FakeSession(scalar=[None])
|
||||
service = AgentRosterService(fake_session)
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
tenant_id="tenant-1",
|
||||
name="Analyst",
|
||||
description="old",
|
||||
agent_kind=AgentKind.DIFY_AGENT,
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
status=AgentStatus.ACTIVE,
|
||||
active_config_snapshot_id="version-4",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(service, "_get_agent", lambda **kwargs: agent)
|
||||
|
||||
with pytest.raises(roster_service.AgentVersionNotFoundError):
|
||||
service.restore_agent_version(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
version_id="version-2",
|
||||
account_id="account-1",
|
||||
)
|
||||
|
||||
assert agent.active_config_snapshot_id == "version-4"
|
||||
assert fake_session.added == []
|
||||
assert fake_session.commits == 0
|
||||
|
||||
|
||||
def test_app_list_all_excludes_agent_apps_by_default():
|
||||
filters = AppService._build_app_list_filters(
|
||||
"account-1", "tenant-1", AppListParams(mode="all"), FakeSession(scalar=None, scalars=None)
|
||||
|
||||
@@ -83,6 +83,7 @@ def test_standardize_creates_two_drive_owned_toolfiles_and_commits():
|
||||
assert items[0].is_skill is True
|
||||
assert items[0].skill_metadata is not None
|
||||
assert items[0].skill_metadata.name == "PDF Toolkit"
|
||||
assert items[0].skill_metadata.manifest_files == ["SKILL.md", "scripts/run.py"]
|
||||
assert items[1].is_skill is False
|
||||
|
||||
# The returned upload response carries only the drive-derived fields the UI needs.
|
||||
|
||||
@@ -620,6 +620,45 @@ class TestMyPermissions:
|
||||
assert out.dataset.default_permission_keys == dataset_keys
|
||||
assert out.app.overrides == []
|
||||
assert out.dataset.overrides == []
|
||||
if role == "owner":
|
||||
assert "billing.view" in out.workspace.permission_keys
|
||||
assert "snippets.management" in out.workspace.permission_keys
|
||||
assert "app.acl.preview" in out.workspace.permission_keys
|
||||
assert "dataset.acl.preview" in out.workspace.permission_keys
|
||||
assert "app.acl.preview" in out.app.default_permission_keys
|
||||
assert "dataset.acl.preview" in out.dataset.default_permission_keys
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("role", "expected_snippet_keys"),
|
||||
[
|
||||
("owner", {"snippets.create_and_modify", "snippets.management"}),
|
||||
("admin", {"snippets.create_and_modify", "snippets.management"}),
|
||||
("editor", {"snippets.create_and_modify"}),
|
||||
("normal", set()),
|
||||
("dataset_operator", set()),
|
||||
],
|
||||
)
|
||||
def test_get_uses_legacy_snippet_permissions_when_rbac_disabled(
|
||||
self,
|
||||
mock_send: MagicMock,
|
||||
role: str,
|
||||
expected_snippet_keys: set[str],
|
||||
):
|
||||
mock_session = MagicMock()
|
||||
mock_session.__enter__.return_value = mock_session
|
||||
mock_session.scalar.return_value = role
|
||||
with (
|
||||
patch(f"{MODULE}.dify_config.RBAC_ENABLED", False),
|
||||
patch(f"{MODULE}.session_factory.create_session", return_value=mock_session),
|
||||
):
|
||||
out = svc.RBACService.MyPermissions.get("tenant-1", "acct-1")
|
||||
|
||||
actual_snippet_keys = {
|
||||
permission_key for permission_key in out.workspace.permission_keys if permission_key.startswith("snippets.")
|
||||
}
|
||||
|
||||
mock_send.assert_not_called()
|
||||
assert actual_snippet_keys == expected_snippet_keys
|
||||
|
||||
def test_get_returns_empty_when_role_missing_and_rbac_disabled(self, mock_send: MagicMock):
|
||||
mock_session = MagicMock()
|
||||
@@ -668,7 +707,8 @@ class TestMemberRoles:
|
||||
}
|
||||
],
|
||||
}
|
||||
out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2")
|
||||
with patch(f"{MODULE}.dify_config.RBAC_ENABLED", True):
|
||||
out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2")
|
||||
call = _call_args(mock_send)
|
||||
assert call.method == "GET"
|
||||
assert call.endpoint == "/rbac/members/rbac-roles"
|
||||
@@ -676,6 +716,33 @@ class TestMemberRoles:
|
||||
assert out.account_id == "acct-2"
|
||||
assert out.roles[0].name == "Member"
|
||||
|
||||
def test_get_legacy_role_includes_permission_keys(self, mock_send: MagicMock):
|
||||
session = MagicMock()
|
||||
session.scalar.return_value = svc.TenantAccountRole.EDITOR
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.dify_config.RBAC_ENABLED", False),
|
||||
patch(f"{MODULE}.session_factory.create_session") as create_session,
|
||||
):
|
||||
create_session.return_value.__enter__.return_value = session
|
||||
out = svc.RBACService.MemberRoles.get("tenant-1", "acct-1", "acct-2")
|
||||
|
||||
mock_send.assert_not_called()
|
||||
assert out.account_id == "acct-2"
|
||||
assert out.roles[0].name == "editor"
|
||||
assert out.roles[0].permission_keys == list(
|
||||
dict.fromkeys(
|
||||
[
|
||||
*svc._LEGACY_WORKSPACE_EDITOR_KEYS,
|
||||
*svc._LEGACY_APP_EDITOR_KEYS,
|
||||
*svc._LEGACY_DATASET_EDITOR_KEYS,
|
||||
]
|
||||
)
|
||||
)
|
||||
assert "snippets.create_and_modify" in out.roles[0].permission_keys
|
||||
assert "app.acl.preview" in out.roles[0].permission_keys
|
||||
assert "dataset.acl.preview" in out.roles[0].permission_keys
|
||||
|
||||
def test_replace(self, mock_send: MagicMock):
|
||||
mock_send.return_value = {"account_id": "acct-2", "roles": []}
|
||||
svc.RBACService.MemberRoles.replace(
|
||||
|
||||
@@ -704,3 +704,104 @@ def test_manifest_items_carry_created_at_for_inspector():
|
||||
_commit("files/x.txt", tf)
|
||||
items = AgentDriveService().manifest(tenant_id=TENANT, agent_id=AGENT)
|
||||
assert items[0]["created_at"] is None or isinstance(items[0]["created_at"], int)
|
||||
|
||||
|
||||
# ── DIFY-2517: skill catalog / inspect ───────────────────────────────────────
|
||||
|
||||
|
||||
def _commit_skill(*, manifest_files: list[str] | None = None) -> None:
|
||||
md = _seed_tool_file(name="SKILL.md")
|
||||
zf = _seed_tool_file(name="full.zip")
|
||||
AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key="pdf-toolkit/SKILL.md",
|
||||
file_ref={"kind": "tool_file", "id": md},
|
||||
value_owned_by_drive=True,
|
||||
is_skill=True,
|
||||
skill_metadata=DriveSkillMetadata(
|
||||
name="PDF Toolkit",
|
||||
description="Work with PDFs.",
|
||||
manifest_files=manifest_files,
|
||||
),
|
||||
),
|
||||
DriveCommitItem(
|
||||
key="pdf-toolkit/.DIFY-SKILL-FULL.zip",
|
||||
file_ref={"kind": "tool_file", "id": zf},
|
||||
value_owned_by_drive=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def test_list_skills_uses_canonical_skill_rows():
|
||||
_commit_skill(manifest_files=["SKILL.md", "scripts/run.py"])
|
||||
|
||||
skills = AgentDriveService().list_skills(tenant_id=TENANT, agent_id=AGENT)
|
||||
|
||||
created_at = skills[0].pop("created_at")
|
||||
assert skills == [
|
||||
{
|
||||
"path": "pdf-toolkit",
|
||||
"skill_md_key": "pdf-toolkit/SKILL.md",
|
||||
"archive_key": "pdf-toolkit/.DIFY-SKILL-FULL.zip",
|
||||
"name": "PDF Toolkit",
|
||||
"description": "Work with PDFs.",
|
||||
"size": 5,
|
||||
"mime_type": "text/plain",
|
||||
"hash": None,
|
||||
}
|
||||
]
|
||||
assert created_at is None or isinstance(created_at, int)
|
||||
|
||||
|
||||
def test_inspect_skill_returns_manifest_files_and_file_tree():
|
||||
_commit_skill(manifest_files=["SKILL.md", "references/guide.md", "scripts/run.py"])
|
||||
|
||||
with patch("services.agent_drive_service.storage") as storage_mock:
|
||||
storage_mock.load_stream.return_value = iter([b"# PDF Toolkit\n"])
|
||||
result = AgentDriveService().inspect_skill(tenant_id=TENANT, agent_id=AGENT, skill_path="pdf-toolkit")
|
||||
|
||||
assert result["source"] == "skill_md"
|
||||
assert result["warnings"] == []
|
||||
assert [file["path"] for file in result["files"]] == ["SKILL.md", "references/guide.md", "scripts/run.py"]
|
||||
assert result["files"][0]["available_in_drive"] is True
|
||||
assert result["files"][1]["available_in_drive"] is False
|
||||
assert result["file_tree"][0]["name"] == "references"
|
||||
assert result["file_tree"][1]["name"] == "scripts"
|
||||
assert result["file_tree"][2]["name"] == "SKILL.md"
|
||||
assert result["skill_md"]["text"] == "# PDF Toolkit\n"
|
||||
|
||||
|
||||
def test_inspect_skill_falls_back_to_drive_keys_when_manifest_missing():
|
||||
_commit_skill(manifest_files=None)
|
||||
|
||||
with patch("services.agent_drive_service.storage") as storage_mock:
|
||||
storage_mock.load_stream.return_value = iter([b"# PDF Toolkit\n"])
|
||||
result = AgentDriveService().inspect_skill(tenant_id=TENANT, agent_id=AGENT, skill_path="pdf-toolkit")
|
||||
|
||||
assert result["warnings"] == ["manifest_files_unavailable"]
|
||||
assert [file["path"] for file in result["files"]] == ["SKILL.md"]
|
||||
|
||||
|
||||
def test_skill_metadata_rejects_non_canonical_rows():
|
||||
tf = _seed_tool_file(name="not-skill.md")
|
||||
with pytest.raises(AgentDriveError) as exc_info:
|
||||
AgentDriveService().commit(
|
||||
tenant_id=TENANT,
|
||||
user_id=USER,
|
||||
agent_id=AGENT,
|
||||
items=[
|
||||
DriveCommitItem(
|
||||
key="files/not-skill.md",
|
||||
file_ref={"kind": "tool_file", "id": tf},
|
||||
value_owned_by_drive=True,
|
||||
is_skill=True,
|
||||
skill_metadata=DriveSkillMetadata(name="Bad"),
|
||||
)
|
||||
],
|
||||
)
|
||||
assert exc_info.value.code == "invalid_skill_key"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import dataclasses
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -672,7 +673,7 @@ def test_enqueue_resume_workflow_not_found(mocker: MockerFixture, mock_session_f
|
||||
assert "WorkflowRun not found" in str(excinfo.value)
|
||||
|
||||
|
||||
def test_enqueue_resume_app_not_found(mocker: MockerFixture, mock_session_factory):
|
||||
def test_enqueue_resume_app_not_found(mocker, mock_session_factory, caplog):
|
||||
session_factory, session = mock_session_factory
|
||||
service = HumanInputService(session_factory)
|
||||
|
||||
@@ -687,10 +688,10 @@ def test_enqueue_resume_app_not_found(mocker: MockerFixture, mock_session_factor
|
||||
)
|
||||
|
||||
session.execute.return_value.scalar_one_or_none.return_value = None
|
||||
logger_spy = mocker.patch("services.human_input_service.logger")
|
||||
|
||||
service.enqueue_resume("workflow-run-id")
|
||||
logger_spy.error.assert_called_once()
|
||||
with caplog.at_level(logging.ERROR, logger="services.human_input_service"):
|
||||
service.enqueue_resume("workflow-run-id")
|
||||
assert any(r.levelno >= logging.ERROR for r in caplog.records)
|
||||
|
||||
|
||||
def test_is_globally_expired_zero_timeout(
|
||||
|
||||
@@ -4,7 +4,7 @@ from unittest.mock import MagicMock, patch
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from services.operation_service import OperationService
|
||||
from services.operation_service import OPERATION_REQUEST_TIMEOUT, OperationService
|
||||
|
||||
|
||||
class TestOperationService:
|
||||
@@ -44,6 +44,7 @@ class TestOperationService:
|
||||
assert kwargs["json"] == json_data
|
||||
assert kwargs["headers"]["Billing-Api-Secret-Key"] == "s3cr3t"
|
||||
assert kwargs["headers"]["Content-Type"] == "application/json"
|
||||
assert kwargs["timeout"] == OPERATION_REQUEST_TIMEOUT
|
||||
|
||||
@patch("httpx.request")
|
||||
def test_should_propagate_httpx_error_when__send_request_raises(
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
@@ -532,7 +533,10 @@ def test_vectorize_summary_error_handler_tries_chunk_id_lookup_and_can_warn_not_
|
||||
error_session.commit.assert_not_called()
|
||||
|
||||
|
||||
def test_update_summary_record_error_warns_when_missing(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_update_summary_record_error_warns_when_missing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
dataset = _dataset()
|
||||
segment = _segment()
|
||||
|
||||
@@ -544,14 +548,15 @@ def test_update_summary_record_error_warns_when_missing(monkeypatch: pytest.Monk
|
||||
SimpleNamespace(create_session=MagicMock(return_value=_SessionContext(session))),
|
||||
)
|
||||
|
||||
logger_mock = MagicMock()
|
||||
monkeypatch.setattr(summary_module, "logger", logger_mock)
|
||||
|
||||
SummaryIndexService.update_summary_record_error(segment, dataset, "err")
|
||||
logger_mock.warning.assert_called_once()
|
||||
with caplog.at_level(logging.WARNING, logger="services.summary_index_service"):
|
||||
SummaryIndexService.update_summary_record_error(segment, dataset, "err")
|
||||
assert any(r.levelno >= logging.WARNING for r in caplog.records)
|
||||
|
||||
|
||||
def test_generate_and_vectorize_summary_creates_missing_record_and_logs_usage(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_generate_and_vectorize_summary_creates_missing_record_and_logs_usage(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
dataset = _dataset()
|
||||
segment = _segment()
|
||||
|
||||
@@ -567,12 +572,10 @@ def test_generate_and_vectorize_summary_creates_missing_record_and_logs_usage(mo
|
||||
monkeypatch.setattr(SummaryIndexService, "generate_summary_for_segment", MagicMock(return_value=("sum", usage)))
|
||||
monkeypatch.setattr(SummaryIndexService, "vectorize_summary", MagicMock(return_value=None))
|
||||
|
||||
logger_mock = MagicMock()
|
||||
monkeypatch.setattr(summary_module, "logger", logger_mock)
|
||||
|
||||
result = SummaryIndexService.generate_and_vectorize_summary(segment, dataset, {"enable": True})
|
||||
assert result.status in {SummaryStatus.GENERATING, SummaryStatus.COMPLETED}
|
||||
logger_mock.info.assert_called()
|
||||
with caplog.at_level(logging.INFO, logger="services.summary_index_service"):
|
||||
result = SummaryIndexService.generate_and_vectorize_summary(segment, dataset, {"enable": True})
|
||||
assert result.status in {SummaryStatus.GENERATING, SummaryStatus.COMPLETED}
|
||||
assert any(r.levelno >= logging.INFO for r in caplog.records)
|
||||
|
||||
|
||||
def test_generate_summaries_for_document_skip_conditions(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -759,6 +762,7 @@ def test_enable_summaries_for_segments_no_summaries_noop(monkeypatch: pytest.Mon
|
||||
|
||||
def test_enable_summaries_for_segments_skips_segment_or_content_and_handles_vectorize_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
dataset = _dataset()
|
||||
summary1 = _summary_record(summary_content="sum", node_id="n1")
|
||||
@@ -786,12 +790,11 @@ def test_enable_summaries_for_segments_skips_segment_or_content_and_handles_vect
|
||||
SimpleNamespace(create_session=MagicMock(return_value=_SessionContext(session))),
|
||||
)
|
||||
|
||||
logger_mock = MagicMock()
|
||||
monkeypatch.setattr(summary_module, "logger", logger_mock)
|
||||
monkeypatch.setattr(SummaryIndexService, "vectorize_summary", MagicMock(side_effect=RuntimeError("boom")))
|
||||
|
||||
SummaryIndexService.enable_summaries_for_segments(dataset)
|
||||
logger_mock.exception.assert_called_once()
|
||||
with caplog.at_level(logging.ERROR, logger="services.summary_index_service"):
|
||||
SummaryIndexService.enable_summaries_for_segments(dataset)
|
||||
assert any(r.levelno >= logging.ERROR for r in caplog.records)
|
||||
session.commit.assert_called_once()
|
||||
|
||||
|
||||
@@ -859,7 +862,10 @@ def test_update_summary_for_segment_empty_content_deletes_existing(monkeypatch:
|
||||
session.commit.assert_called_once()
|
||||
|
||||
|
||||
def test_update_summary_for_segment_empty_content_delete_vector_warns(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_update_summary_for_segment_empty_content_delete_vector_warns(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
dataset = _dataset()
|
||||
segment = _segment()
|
||||
record = _summary_record(summary_content="old", node_id="n1")
|
||||
@@ -875,11 +881,10 @@ def test_update_summary_for_segment_empty_content_delete_vector_warns(monkeypatc
|
||||
vector_instance = MagicMock()
|
||||
vector_instance.delete_by_ids.side_effect = RuntimeError("boom")
|
||||
monkeypatch.setattr(summary_module, "Vector", MagicMock(return_value=vector_instance))
|
||||
logger_mock = MagicMock()
|
||||
monkeypatch.setattr(summary_module, "logger", logger_mock)
|
||||
|
||||
assert SummaryIndexService.update_summary_for_segment(segment, dataset, "") is None
|
||||
logger_mock.warning.assert_called()
|
||||
with caplog.at_level(logging.WARNING, logger="services.summary_index_service"):
|
||||
assert SummaryIndexService.update_summary_for_segment(segment, dataset, "") is None
|
||||
assert any(r.levelno >= logging.WARNING for r in caplog.records)
|
||||
|
||||
|
||||
def test_update_summary_for_segment_empty_content_no_record_noop(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@@ -923,7 +928,10 @@ def test_update_summary_for_segment_updates_existing_and_vectorizes(monkeypatch:
|
||||
session.commit.assert_called()
|
||||
|
||||
|
||||
def test_update_summary_for_segment_existing_vector_delete_warns(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
def test_update_summary_for_segment_existing_vector_delete_warns(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
dataset = _dataset()
|
||||
segment = _segment()
|
||||
record = _summary_record(summary_content="old", node_id="n1")
|
||||
@@ -940,11 +948,10 @@ def test_update_summary_for_segment_existing_vector_delete_warns(monkeypatch: py
|
||||
vector_instance.delete_by_ids.side_effect = RuntimeError("boom")
|
||||
monkeypatch.setattr(summary_module, "Vector", MagicMock(return_value=vector_instance))
|
||||
monkeypatch.setattr(SummaryIndexService, "vectorize_summary", MagicMock(return_value=None))
|
||||
logger_mock = MagicMock()
|
||||
monkeypatch.setattr(summary_module, "logger", logger_mock)
|
||||
|
||||
SummaryIndexService.update_summary_for_segment(segment, dataset, "new")
|
||||
logger_mock.warning.assert_called()
|
||||
with caplog.at_level(logging.WARNING, logger="services.summary_index_service"):
|
||||
SummaryIndexService.update_summary_for_segment(segment, dataset, "new")
|
||||
assert any(r.levelno >= logging.WARNING for r in caplog.records)
|
||||
|
||||
|
||||
def test_update_summary_for_segment_existing_vectorize_failure_returns_error_record(
|
||||
|
||||
@@ -253,7 +253,7 @@ def test_add_trigger_subscription_should_raise_error_when_provider_limit_reached
|
||||
mock_session: MagicMock,
|
||||
provider_id: TriggerProviderID,
|
||||
provider_controller: MagicMock,
|
||||
caplog,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
# Arrange
|
||||
_patch_redis_lock(mocker)
|
||||
@@ -274,7 +274,7 @@ def test_add_trigger_subscription_should_raise_error_when_provider_limit_reached
|
||||
properties={},
|
||||
credentials={},
|
||||
)
|
||||
assert sum(1 for r in caplog.records if r.levelno >= logging.ERROR) == 1
|
||||
assert any(r.levelno >= logging.ERROR for r in caplog.records)
|
||||
|
||||
|
||||
def test_add_trigger_subscription_should_raise_error_when_name_exists(
|
||||
|
||||
@@ -269,7 +269,8 @@ def test_create_segments_vector_parent_child_uses_default_embedding_model_when_p
|
||||
|
||||
|
||||
def test_create_segments_vector_parent_child_missing_document_logs_warning_and_continues(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
dataset = _make_dataset(doc_form=vector_service_module.IndexStructureType.PARENT_CHILD_INDEX)
|
||||
segment = _make_segment()
|
||||
@@ -290,7 +291,7 @@ def test_create_segments_vector_parent_child_missing_document_logs_warning_and_c
|
||||
VectorService.create_segments_vector(
|
||||
None, [segment], dataset, vector_service_module.IndexStructureType.PARENT_CHILD_INDEX
|
||||
)
|
||||
assert "Expected DatasetDocument record to exist, but none was found" in caplog.text
|
||||
assert any(r.levelno >= logging.WARNING for r in caplog.records)
|
||||
index_processor.load.assert_not_called()
|
||||
|
||||
|
||||
@@ -614,7 +615,8 @@ def test_update_multimodel_vector_commits_when_no_upload_files_found(monkeypatch
|
||||
|
||||
|
||||
def test_update_multimodel_vector_adds_bindings_and_vectors_and_skips_missing_upload_files(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
dataset = _make_dataset(indexing_technique=IndexTechniqueType.HIGH_QUALITY, is_multimodal=True)
|
||||
segment = _make_segment(segment_id="seg-1", tenant_id="tenant-1", attachments=[{"id": "old-1"}])
|
||||
@@ -631,8 +633,7 @@ def test_update_multimodel_vector_adds_bindings_and_vectors_and_skips_missing_up
|
||||
|
||||
with caplog.at_level(logging.WARNING, logger="services.vector_service"):
|
||||
VectorService.update_multimodel_vector(segment=segment, attachment_ids=["file-1", "missing"], dataset=dataset)
|
||||
|
||||
assert "Upload file not found for attachment_id" in caplog.text
|
||||
assert any(r.levelno >= logging.WARNING for r in caplog.records)
|
||||
db_mock.session.add_all.assert_called_once()
|
||||
bindings = db_mock.session.add_all.call_args.args[0]
|
||||
assert len(bindings) == 1
|
||||
@@ -671,7 +672,8 @@ def test_update_multimodel_vector_updates_bindings_without_multimodal_vector_ops
|
||||
|
||||
|
||||
def test_update_multimodel_vector_rolls_back_and_reraises_on_error(
|
||||
monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
dataset = _make_dataset(indexing_technique=IndexTechniqueType.HIGH_QUALITY, is_multimodal=True)
|
||||
segment = _make_segment(segment_id="seg-1", tenant_id="tenant-1", attachments=[{"id": "old-1"}])
|
||||
@@ -691,7 +693,5 @@ def test_update_multimodel_vector_rolls_back_and_reraises_on_error(
|
||||
with pytest.raises(RuntimeError, match="boom"):
|
||||
VectorService.update_multimodel_vector(segment=segment, attachment_ids=["file-1"], dataset=dataset)
|
||||
|
||||
exception_records = [r for r in caplog.records if r.levelname == "ERROR"]
|
||||
assert len(exception_records) == 1
|
||||
assert "Failed to update multimodal vector for segment" in exception_records[0].getMessage()
|
||||
assert any(r.levelno >= logging.ERROR for r in caplog.records)
|
||||
db_mock.session.rollback.assert_called_once()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
@@ -31,21 +32,21 @@ class TestWebhookServiceExtractionFallbacks:
|
||||
self,
|
||||
flask_app: Flask,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
warning_mock = MagicMock()
|
||||
monkeypatch.setattr(service_module.logger, "warning", warning_mock)
|
||||
webhook_trigger = MagicMock()
|
||||
|
||||
with flask_app.test_request_context(
|
||||
"/webhook",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/vnd.custom"},
|
||||
data="plain content",
|
||||
):
|
||||
result = WebhookService.extract_webhook_data(webhook_trigger)
|
||||
with caplog.at_level(logging.WARNING, logger="services.trigger.webhook_service"):
|
||||
with flask_app.test_request_context(
|
||||
"/webhook",
|
||||
method="POST",
|
||||
headers={"Content-Type": "application/vnd.custom"},
|
||||
data="plain content",
|
||||
):
|
||||
result = WebhookService.extract_webhook_data(webhook_trigger)
|
||||
|
||||
assert result["body"] == {"raw": "plain content"}
|
||||
warning_mock.assert_called_once()
|
||||
assert result["body"] == {"raw": "plain content"}
|
||||
assert any(r.levelno >= logging.WARNING for r in caplog.records)
|
||||
|
||||
def test_extract_webhook_data_should_raise_for_request_too_large(
|
||||
self,
|
||||
@@ -171,14 +172,13 @@ class TestWebhookServiceValidationAndConversion:
|
||||
def test_validate_json_value_should_return_original_for_unmapped_supported_segment_type(
|
||||
self,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
warning_mock = MagicMock()
|
||||
monkeypatch.setattr(service_module.logger, "warning", warning_mock)
|
||||
with caplog.at_level(logging.WARNING, logger="services.trigger.webhook_service"):
|
||||
result = WebhookService._validate_json_value("param", {"x": 1}, "unsupported-type")
|
||||
|
||||
result = WebhookService._validate_json_value("param", {"x": 1}, "unsupported-type")
|
||||
|
||||
assert result == {"x": 1}
|
||||
warning_mock.assert_called_once()
|
||||
assert result == {"x": 1}
|
||||
assert any(r.levelno >= logging.WARNING for r in caplog.records)
|
||||
|
||||
def test_validate_and_convert_value_should_wrap_conversion_errors(self) -> None:
|
||||
with pytest.raises(ValueError, match="validation failed"):
|
||||
|
||||
@@ -16,12 +16,14 @@ from core.app.app_config.entities import WorkflowUIBasedAppConfig
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom, WorkflowAppGenerateEntity
|
||||
from core.app.entities.task_entities import StreamEvent
|
||||
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext, _WorkflowGenerateEntityWrapper
|
||||
from core.workflow.human_input_policy import FormDisposition, HumanInputSurface
|
||||
from graphon.entities.pause_reason import HumanInputRequired
|
||||
from graphon.enums import WorkflowExecutionStatus, WorkflowNodeExecutionStatus
|
||||
from graphon.nodes.human_input.entities import SelectInputConfig, StringListSource
|
||||
from graphon.nodes.human_input.enums import ValueSourceType
|
||||
from graphon.runtime import GraphRuntimeState, VariablePool
|
||||
from models.enums import CreatorUserRole
|
||||
from models.human_input import RecipientType
|
||||
from models.model import AppMode
|
||||
from models.workflow import WorkflowRun
|
||||
from repositories.api_workflow_node_execution_repository import WorkflowNodeExecutionSnapshot
|
||||
@@ -763,7 +765,11 @@ def test_build_snapshot_events_preserves_public_form_token(monkeypatch: pytest.M
|
||||
snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED)
|
||||
resumption_context = _build_resumption_context("task-ctx")
|
||||
monkeypatch.setattr(
|
||||
service_module, "load_form_tokens_by_form_id", lambda form_ids, session=None, surface=None: {"form-1": "wtok"}
|
||||
service_module,
|
||||
"load_form_dispositions_by_form_id",
|
||||
lambda form_ids, session=None, surface=None: {
|
||||
"form-1": FormDisposition(form_token="wtok", approval_channels=[])
|
||||
},
|
||||
)
|
||||
session_maker = _SessionMaker(
|
||||
SimpleNamespace(
|
||||
@@ -803,12 +809,99 @@ def test_build_snapshot_events_preserves_public_form_token(monkeypatch: pytest.M
|
||||
assert pause_data["reasons"][0]["expiration_time"] == int(datetime(2024, 1, 1, tzinfo=UTC).timestamp())
|
||||
|
||||
|
||||
def _build_recipient_snapshot_events(recipients: Sequence[Any]) -> list[Mapping[str, Any]]:
|
||||
"""Drive the reconnect snapshot pause path for the OPENAPI surface.
|
||||
|
||||
Lets the real disposition loader run against a fake session whose ``scalars``
|
||||
yields the given recipients, so the reconnect path derives the same token and
|
||||
approval channels as the live path for the same recipient set.
|
||||
"""
|
||||
workflow_run = _build_workflow_run(WorkflowExecutionStatus.PAUSED)
|
||||
snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED)
|
||||
resumption_context = _build_resumption_context("task-ctx")
|
||||
expiration_time = datetime(2024, 1, 1, tzinfo=UTC)
|
||||
session_maker = _SessionMaker(
|
||||
SimpleNamespace(
|
||||
execute=lambda _stmt: [("form-1", expiration_time, '{"display_in_ui": true}')],
|
||||
scalars=lambda _stmt: list(recipients),
|
||||
)
|
||||
)
|
||||
pause_entity = _FakePauseEntity(
|
||||
pause_id="pause-1",
|
||||
workflow_run_id="run-1",
|
||||
paused_at_value=expiration_time,
|
||||
pause_reasons=[
|
||||
HumanInputRequired(
|
||||
form_id="form-1",
|
||||
form_content="content",
|
||||
node_id="node-1",
|
||||
node_title="Human Input",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
return _build_snapshot_events(
|
||||
workflow_run=workflow_run,
|
||||
node_snapshots=[snapshot],
|
||||
task_id="task-ctx",
|
||||
message_context=None,
|
||||
pause_entity=pause_entity,
|
||||
resumption_context=resumption_context,
|
||||
session_maker=cast(sessionmaker[Session], session_maker),
|
||||
human_input_surface=HumanInputSurface.OPENAPI,
|
||||
)
|
||||
|
||||
|
||||
def test_reconnect_pause_without_web_app_recipient_emits_approval_channels() -> None:
|
||||
events = _build_recipient_snapshot_events(
|
||||
recipients=[
|
||||
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.EMAIL_MEMBER, access_token="email-token"),
|
||||
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"),
|
||||
],
|
||||
)
|
||||
|
||||
human_input_event = events[-2]
|
||||
assert human_input_event["event"] == StreamEvent.HUMAN_INPUT_REQUIRED
|
||||
assert human_input_event["data"]["form_token"] is None
|
||||
assert human_input_event["data"]["approval_channels"] == ["console", "email"]
|
||||
|
||||
pause_data = events[-1]["data"]
|
||||
assert pause_data["reasons"][0]["form_token"] is None
|
||||
assert pause_data["reasons"][0]["approval_channels"] == ["console", "email"]
|
||||
|
||||
|
||||
def test_reconnect_pause_with_web_app_recipient_sets_token_and_channels() -> None:
|
||||
events = _build_recipient_snapshot_events(
|
||||
recipients=[
|
||||
SimpleNamespace(
|
||||
form_id="form-1",
|
||||
recipient_type=RecipientType.STANDALONE_WEB_APP,
|
||||
access_token="web-app-token",
|
||||
),
|
||||
SimpleNamespace(form_id="form-1", recipient_type=RecipientType.BACKSTAGE, access_token="backstage-token"),
|
||||
],
|
||||
)
|
||||
|
||||
human_input_event = events[-2]
|
||||
assert human_input_event["event"] == StreamEvent.HUMAN_INPUT_REQUIRED
|
||||
assert human_input_event["data"]["form_token"] == "web-app-token"
|
||||
assert human_input_event["data"]["approval_channels"] == ["console"]
|
||||
|
||||
pause_data = events[-1]["data"]
|
||||
assert pause_data["reasons"][0]["form_token"] == "web-app-token"
|
||||
assert pause_data["reasons"][0]["approval_channels"] == ["console"]
|
||||
|
||||
|
||||
def test_build_snapshot_events_resolves_pause_reason_select_options(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
workflow_run = _build_workflow_run(WorkflowExecutionStatus.PAUSED)
|
||||
snapshot = _build_snapshot(WorkflowNodeExecutionStatus.PAUSED)
|
||||
resumption_context = _build_resumption_context("task-ctx", select_options=["approve", "reject"])
|
||||
monkeypatch.setattr(
|
||||
service_module, "load_form_tokens_by_form_id", lambda form_ids, session=None, surface=None: {"form-1": "wtok"}
|
||||
service_module,
|
||||
"load_form_dispositions_by_form_id",
|
||||
lambda form_ids, session=None, surface=None: {
|
||||
"form-1": FormDisposition(form_token="wtok", approval_channels=[])
|
||||
},
|
||||
)
|
||||
session_maker = _SessionMaker(
|
||||
SimpleNamespace(
|
||||
@@ -886,7 +979,11 @@ def test_build_workflow_event_stream_loads_pause_tokens_without_flask_app_contex
|
||||
service_module, "_load_resumption_context", MagicMock(return_value=_build_resumption_context("task-1"))
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
service_module, "load_form_tokens_by_form_id", lambda form_ids, session=None, surface=None: {"form-1": "wtok"}
|
||||
service_module,
|
||||
"load_form_dispositions_by_form_id",
|
||||
lambda form_ids, session=None, surface=None: {
|
||||
"form-1": FormDisposition(form_token="wtok", approval_channels=[])
|
||||
},
|
||||
)
|
||||
|
||||
session = SimpleNamespace(
|
||||
|
||||
Reference in New Issue
Block a user