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:
Yanli 盐粒
2026-06-22 16:43:54 +08:00
215 changed files with 6267 additions and 3468 deletions
@@ -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(