mirror of
https://github.com/langgenius/dify.git
synced 2026-09-24 23:22:26 +08:00
chore(agent-v2): sync changes (#38442)
Co-authored-by: Joel <iamjoel007@gmail.com> Co-authored-by: zyssyz123 <916125788@qq.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: 林玮 (Jade Lin) <linw1995@icloud.com> Co-authored-by: 盐粒 Yanli <mail@yanli.one>
This commit is contained in:
co-authored by
Joel
zyssyz123
autofix-ci[bot]
林玮
盐粒 Yanli
parent
bdb3469ca0
commit
d0ea5a5e0d
@@ -0,0 +1,156 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from controllers.common import agent_app_parameters
|
||||
from controllers.common.agent_app_parameters import get_published_agent_app_feature_dict_and_user_input_form
|
||||
from core.app.app_config.common.parameters_mapping import get_parameters_from_feature_dict
|
||||
from core.app.apps.agent_app.errors import AgentAppGeneratorError, AgentAppNotPublishedError
|
||||
|
||||
|
||||
def test_published_agent_app_parameters_use_soul_file_upload(monkeypatch):
|
||||
app_model_config = SimpleNamespace(
|
||||
to_dict=lambda: {
|
||||
"opening_statement": "Hi from legacy presentation config",
|
||||
"file_upload": {
|
||||
"enabled": False,
|
||||
"image": {"enabled": False},
|
||||
},
|
||||
}
|
||||
)
|
||||
app_model = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
bound_agent_id="agent-1",
|
||||
app_model_config=app_model_config,
|
||||
)
|
||||
agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
active_config_snapshot_id="snapshot-1",
|
||||
active_config_is_published=True,
|
||||
)
|
||||
snapshot = SimpleNamespace(
|
||||
config_snapshot_dict={
|
||||
"app_features": {
|
||||
"file_upload": {
|
||||
"enabled": True,
|
||||
"allowed_file_extensions": ["PNG"],
|
||||
"allowed_file_types": ["image"],
|
||||
"allowed_file_upload_methods": ["local_file"],
|
||||
"image": {"enabled": True},
|
||||
"number_limits": 2,
|
||||
}
|
||||
},
|
||||
"app_variables": [{"name": "topic", "type": "string", "required": True}],
|
||||
}
|
||||
)
|
||||
query_results = iter([agent, snapshot])
|
||||
monkeypatch.setattr(agent_app_parameters.db.session, "scalar", lambda _: next(query_results))
|
||||
|
||||
features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form(app_model)
|
||||
parameters = get_parameters_from_feature_dict(features_dict=features_dict, user_input_form=user_input_form)
|
||||
|
||||
assert parameters["opening_statement"] == "Hi from legacy presentation config"
|
||||
assert parameters["file_upload"] == {
|
||||
"enabled": True,
|
||||
"allowed_file_extensions": ["PNG"],
|
||||
"allowed_file_types": ["image"],
|
||||
"allowed_file_upload_methods": ["local_file"],
|
||||
"image": {"enabled": True},
|
||||
"number_limits": 2,
|
||||
}
|
||||
assert parameters["user_input_form"] == [{"text-input": {"label": "topic", "variable": "topic", "required": True}}]
|
||||
|
||||
|
||||
def test_published_agent_app_parameters_requires_bound_agent():
|
||||
app_model = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
bound_agent_id=None,
|
||||
app_model_config=None,
|
||||
)
|
||||
|
||||
with pytest.raises(AgentAppGeneratorError, match="no bound Agent"):
|
||||
get_published_agent_app_feature_dict_and_user_input_form(app_model)
|
||||
|
||||
|
||||
def test_published_agent_app_parameters_requires_existing_active_agent(monkeypatch):
|
||||
app_model = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
bound_agent_id="agent-1",
|
||||
app_model_config=None,
|
||||
)
|
||||
monkeypatch.setattr(agent_app_parameters.db.session, "scalar", lambda _: None)
|
||||
|
||||
with pytest.raises(AgentAppGeneratorError, match="no bound Agent"):
|
||||
get_published_agent_app_feature_dict_and_user_input_form(app_model)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("active_config_snapshot_id", "active_config_is_published"),
|
||||
[
|
||||
(None, True),
|
||||
("snapshot-1", False),
|
||||
],
|
||||
)
|
||||
def test_published_agent_app_parameters_requires_published_agent(
|
||||
monkeypatch, active_config_snapshot_id, active_config_is_published
|
||||
):
|
||||
app_model = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
bound_agent_id="agent-1",
|
||||
app_model_config=None,
|
||||
)
|
||||
agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
active_config_snapshot_id=active_config_snapshot_id,
|
||||
active_config_is_published=active_config_is_published,
|
||||
)
|
||||
monkeypatch.setattr(agent_app_parameters.db.session, "scalar", lambda _: agent)
|
||||
|
||||
with pytest.raises(AgentAppNotPublishedError, match="not been published"):
|
||||
get_published_agent_app_feature_dict_and_user_input_form(app_model)
|
||||
|
||||
|
||||
def test_published_agent_app_parameters_requires_published_snapshot(monkeypatch):
|
||||
app_model = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
bound_agent_id="agent-1",
|
||||
app_model_config=None,
|
||||
)
|
||||
agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
active_config_snapshot_id="snapshot-1",
|
||||
active_config_is_published=True,
|
||||
)
|
||||
query_results = iter([agent, None])
|
||||
monkeypatch.setattr(agent_app_parameters.db.session, "scalar", lambda _: next(query_results))
|
||||
|
||||
with pytest.raises(AgentAppGeneratorError, match="published version not found"):
|
||||
get_published_agent_app_feature_dict_and_user_input_form(app_model)
|
||||
|
||||
|
||||
def test_published_agent_app_parameters_allows_missing_legacy_app_model_config(monkeypatch):
|
||||
app_model = SimpleNamespace(
|
||||
tenant_id="tenant-1",
|
||||
bound_agent_id="agent-1",
|
||||
app_model_config=None,
|
||||
)
|
||||
agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
active_config_snapshot_id="snapshot-1",
|
||||
active_config_is_published=True,
|
||||
)
|
||||
snapshot = SimpleNamespace(config_snapshot_dict={})
|
||||
query_results = iter([agent, snapshot])
|
||||
monkeypatch.setattr(agent_app_parameters.db.session, "scalar", lambda _: next(query_results))
|
||||
|
||||
features_dict, user_input_form = get_published_agent_app_feature_dict_and_user_input_form(app_model)
|
||||
|
||||
assert features_dict["file_upload"] == {
|
||||
"allowed_file_extensions": ["JPG", "JPEG", "PNG", "GIF", "WEBP", "SVG"],
|
||||
"allowed_file_types": ["document", "image", "audio", "video"],
|
||||
"allowed_file_upload_methods": ["local_file", "remote_url"],
|
||||
"enabled": True,
|
||||
"image": {"enabled": True},
|
||||
"number_limits": 3,
|
||||
}
|
||||
assert user_input_form == []
|
||||
@@ -5,11 +5,11 @@ from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from dify_agent.client import DifyAgentClientError, DifyAgentHTTPError, DifyAgentTimeoutError
|
||||
from dify_agent.protocol import SandboxListResponse, SandboxReadResponse, SandboxUploadResponse
|
||||
from dify_agent.protocol import SandboxListResponse, SandboxReadResponse
|
||||
|
||||
from controllers.console import agent_app_sandbox as module
|
||||
from models.model import App, AppMode, IconType
|
||||
from services.agent_app_sandbox_service import AgentSandboxInfo, AgentSandboxInspectorError
|
||||
from services.agent_app_sandbox_service import AgentSandboxInfo, AgentSandboxInspectorError, AgentSandboxUploadDownload
|
||||
|
||||
|
||||
class _AgentAppService:
|
||||
@@ -28,11 +28,11 @@ class _AgentAppService:
|
||||
self.calls.append(("read", tenant_id, app_id, conversation_id, path))
|
||||
return SandboxReadResponse(path=path, size=5, truncated=False, binary=False, text="hello")
|
||||
|
||||
def upload_file(self, *, tenant_id: str, app_id: str, conversation_id: str, path: str) -> SandboxUploadResponse:
|
||||
def upload_file(
|
||||
self, *, tenant_id: str, app_id: str, conversation_id: str, path: str
|
||||
) -> AgentSandboxUploadDownload:
|
||||
self.calls.append(("upload", tenant_id, app_id, conversation_id, path))
|
||||
return SandboxUploadResponse(
|
||||
path=path, file={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}
|
||||
)
|
||||
return AgentSandboxUploadDownload(url="https://files.example/report.txt")
|
||||
|
||||
|
||||
class _WorkflowService:
|
||||
@@ -74,11 +74,9 @@ class _WorkflowService:
|
||||
node_id: str,
|
||||
node_execution_id: str | None,
|
||||
path: str,
|
||||
) -> SandboxUploadResponse:
|
||||
) -> AgentSandboxUploadDownload:
|
||||
self.calls.append(("upload", tenant_id, app_id, workflow_run_id, node_id, node_execution_id, path))
|
||||
return SandboxUploadResponse(
|
||||
path=path, file={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}
|
||||
)
|
||||
return AgentSandboxUploadDownload(url="https://files.example/upload.txt")
|
||||
|
||||
|
||||
def _app_model(app_id: str = "app-1") -> App:
|
||||
@@ -143,7 +141,7 @@ def test_agent_app_sandbox_resources_proxy_service(monkeypatch: pytest.MonkeyPat
|
||||
assert info == {"session_id": "abc1234", "workspace_cwd": "~/workspace/abc1234"}
|
||||
assert listing["path"] == "sub/report.txt"
|
||||
assert preview["text"] == "hello"
|
||||
assert upload["file"]["reference"] == "dify-file-ref:file-1"
|
||||
assert upload == {"url": "https://files.example/report.txt"}
|
||||
assert service.calls == [
|
||||
("info", "tenant-1", "app-1", "conv-1", ""),
|
||||
("list", "tenant-1", "app-1", "conv-1", "sub/report.txt"),
|
||||
@@ -203,7 +201,7 @@ def test_workflow_agent_sandbox_resources_proxy_service(monkeypatch: pytest.Monk
|
||||
|
||||
assert listing["path"] == "out.txt"
|
||||
assert preview["text"] == "hello"
|
||||
assert upload["file"]["reference"] == "dify-file-ref:file-1"
|
||||
assert upload == {"url": "https://files.example/upload.txt"}
|
||||
assert service.calls == [
|
||||
("list", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "out.txt"),
|
||||
("read", "tenant-1", "app-1", "run-1", "agent-node", "exec-1", "out.txt"),
|
||||
|
||||
@@ -35,6 +35,7 @@ from controllers.openapi._errors import (
|
||||
RecipientSurfaceMismatch,
|
||||
)
|
||||
from controllers.service_api.app.error import (
|
||||
AgentNotPublishedError,
|
||||
AppUnavailableError,
|
||||
CompletionRequestError,
|
||||
ConversationCompletedError,
|
||||
@@ -306,6 +307,7 @@ ERROR_MATRIX = [
|
||||
(InternalServerError(), 500, "internal_server_error"),
|
||||
(BadGateway("x"), 502, "bad_gateway"),
|
||||
(AppUnavailableError(), 400, "app_unavailable"),
|
||||
(AgentNotPublishedError(), 400, "agent_not_published"),
|
||||
(ConversationCompletedError(), 400, "conversation_completed"),
|
||||
(ProviderNotInitializeError(), 400, "provider_not_initialize"),
|
||||
(ProviderQuotaExceededError(), 400, "provider_quota_exceeded"),
|
||||
|
||||
@@ -9,7 +9,8 @@ import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.service_api.app.app import AppInfoApi, AppMetaApi, AppParameterApi
|
||||
from controllers.service_api.app.error import AppUnavailableError
|
||||
from controllers.service_api.app.error import AgentNotPublishedError, AppUnavailableError
|
||||
from core.app.apps.agent_app.errors import AgentAppNotPublishedError
|
||||
from models.account import TenantStatus
|
||||
from models.model import App, AppMode
|
||||
from tests.unit_tests.conftest import setup_mock_tenant_owner_execute_result
|
||||
@@ -185,6 +186,41 @@ class TestAppParameterApi:
|
||||
]
|
||||
mock_get_agent_parameters.assert_called_once_with(mock_app_model)
|
||||
|
||||
@patch("controllers.service_api.wraps.user_logged_in")
|
||||
@patch("controllers.service_api.wraps.current_app")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
@patch("controllers.service_api.wraps.db")
|
||||
@patch(
|
||||
"controllers.service_api.app.app.get_published_agent_app_feature_dict_and_user_input_form",
|
||||
side_effect=AgentAppNotPublishedError("Agent has not been published"),
|
||||
)
|
||||
def test_get_parameters_for_unpublished_agent_app_raises_friendly_error(
|
||||
self,
|
||||
mock_get_agent_parameters,
|
||||
mock_db,
|
||||
mock_validate_token,
|
||||
mock_current_app,
|
||||
mock_user_logged_in,
|
||||
app: Flask,
|
||||
mock_app_model,
|
||||
):
|
||||
_configure_current_app_mock(mock_current_app)
|
||||
|
||||
mock_app_model.mode = AppMode.AGENT
|
||||
mock_api_token = Mock()
|
||||
mock_api_token.app_id = mock_app_model.id
|
||||
mock_api_token.tenant_id = mock_app_model.tenant_id
|
||||
mock_validate_token.return_value = mock_api_token
|
||||
|
||||
mock_tenant = Mock()
|
||||
mock_tenant.status = TenantStatus.NORMAL
|
||||
mock_db.session.get.side_effect = [mock_app_model, mock_tenant]
|
||||
setup_mock_tenant_owner_execute_result(mock_db, mock_tenant, Mock(current_tenant=mock_tenant))
|
||||
|
||||
with app.test_request_context("/parameters", method="GET", headers={"Authorization": "Bearer test_token"}):
|
||||
with pytest.raises(AgentNotPublishedError):
|
||||
AppParameterApi().get()
|
||||
|
||||
@patch("controllers.service_api.wraps.user_logged_in")
|
||||
@patch("controllers.service_api.wraps.current_app")
|
||||
@patch("controllers.service_api.wraps.validate_and_get_api_token")
|
||||
|
||||
@@ -31,10 +31,12 @@ from controllers.service_api.app.completion import (
|
||||
CompletionStopApi,
|
||||
)
|
||||
from controllers.service_api.app.error import (
|
||||
AgentNotPublishedError,
|
||||
AppUnavailableError,
|
||||
ConversationCompletedError,
|
||||
NotChatAppError,
|
||||
)
|
||||
from core.app.apps.agent_app.errors import AgentAppNotPublishedError
|
||||
from core.errors.error import QuotaExceededError
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
from models.model import App, AppMode, EndUser
|
||||
@@ -516,6 +518,22 @@ class TestChatApiController:
|
||||
with pytest.raises(BadRequest):
|
||||
handler(api, session=Mock(), app_model=app_model, end_user=end_user)
|
||||
|
||||
def test_agent_not_published_error_mapped(self, app: Flask, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(
|
||||
AppGenerateService,
|
||||
"generate",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(AgentAppNotPublishedError("Agent has not been published")),
|
||||
)
|
||||
|
||||
api = ChatApi()
|
||||
handler = unwrap(api.post)
|
||||
app_model = SimpleNamespace(mode=AppMode.AGENT.value)
|
||||
end_user = SimpleNamespace()
|
||||
|
||||
with app.test_request_context("/chat-messages", method="POST", json={"inputs": {}, "query": "hi"}):
|
||||
with pytest.raises(AgentNotPublishedError):
|
||||
handler(api, session=Mock(), app_model=app_model, end_user=end_user)
|
||||
|
||||
|
||||
class TestChatStopApiController:
|
||||
def test_wrong_mode(self, app: Flask) -> None:
|
||||
|
||||
@@ -9,7 +9,8 @@ import pytest
|
||||
from flask import Flask
|
||||
|
||||
from controllers.web.app import AppAccessMode, AppMeta, AppParameterApi, AppWebAuthPermission
|
||||
from controllers.web.error import AppUnavailableError
|
||||
from controllers.web.error import AgentNotPublishedError, AppUnavailableError
|
||||
from core.app.apps.agent_app.errors import AgentAppNotPublishedError
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -80,6 +81,18 @@ class TestAppParameterApi:
|
||||
with pytest.raises(AppUnavailableError):
|
||||
AppParameterApi().get(app_model, SimpleNamespace())
|
||||
|
||||
def test_agent_mode_unpublished_raises_friendly_error(self, app: Flask) -> None:
|
||||
app_model = SimpleNamespace(mode="agent")
|
||||
with (
|
||||
app.test_request_context("/parameters"),
|
||||
patch(
|
||||
"controllers.web.app.get_published_agent_app_feature_dict_and_user_input_form",
|
||||
side_effect=AgentAppNotPublishedError("Agent has not been published"),
|
||||
),
|
||||
):
|
||||
with pytest.raises(AgentNotPublishedError):
|
||||
AppParameterApi().get(app_model, SimpleNamespace())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AppMeta
|
||||
|
||||
@@ -10,6 +10,7 @@ from flask import Flask
|
||||
|
||||
from controllers.web.completion import ChatApi, ChatStopApi, CompletionApi, CompletionStopApi
|
||||
from controllers.web.error import (
|
||||
AgentNotPublishedError,
|
||||
CompletionRequestError,
|
||||
NotChatAppError,
|
||||
NotCompletionAppError,
|
||||
@@ -17,6 +18,7 @@ from controllers.web.error import (
|
||||
ProviderNotInitializeError,
|
||||
ProviderQuotaExceededError,
|
||||
)
|
||||
from core.app.apps.agent_app.errors import AgentAppNotPublishedError
|
||||
from core.errors.error import ModelCurrentlyNotSupportError, ProviderTokenNotInitError, QuotaExceededError
|
||||
from graphon.model_runtime.errors.invoke import InvokeError
|
||||
|
||||
@@ -142,6 +144,19 @@ class TestChatApi:
|
||||
with pytest.raises(CompletionRequestError):
|
||||
ChatApi().post(_chat_app(), _end_user())
|
||||
|
||||
@patch(
|
||||
"controllers.web.completion.AppGenerateService.generate",
|
||||
side_effect=AgentAppNotPublishedError("Agent has not been published"),
|
||||
)
|
||||
@patch("controllers.web.completion.web_ns")
|
||||
def test_agent_not_published_error_mapped(self, mock_ns: MagicMock, mock_gen: MagicMock, app: Flask) -> None:
|
||||
mock_ns.payload = {"inputs": {}, "query": "x"}
|
||||
app_model = SimpleNamespace(id="app-1", mode="agent")
|
||||
|
||||
with app.test_request_context("/chat-messages", method="POST"):
|
||||
with pytest.raises(AgentNotPublishedError):
|
||||
ChatApi().post(app_model, _end_user())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ChatStopApi
|
||||
|
||||
@@ -6,6 +6,7 @@ import pytest
|
||||
|
||||
from controllers.common.errors import InvalidArgumentError, NotFoundError
|
||||
from controllers.web.error import (
|
||||
AgentNotPublishedError,
|
||||
AppMoreLikeThisDisabledError,
|
||||
AppSuggestedQuestionsAfterAnswerDisabledError,
|
||||
AppUnavailableError,
|
||||
@@ -29,6 +30,7 @@ from controllers.web.error import (
|
||||
|
||||
_ERROR_SPECS: list[tuple[type, str, int]] = [
|
||||
(AppUnavailableError, "app_unavailable", 400),
|
||||
(AgentNotPublishedError, "agent_not_published", 400),
|
||||
(NotCompletionAppError, "not_completion_app", 400),
|
||||
(NotChatAppError, "not_chat_app", 400),
|
||||
(NotWorkflowAppError, "not_workflow_app", 400),
|
||||
|
||||
@@ -65,6 +65,36 @@ def test_missing_soul_model_leaves_no_model_key():
|
||||
d = AgentAppConfigManager._synthesize_config_dict(AgentSoulConfig(), None)
|
||||
assert "model" not in d
|
||||
assert d["pre_prompt"] == ""
|
||||
assert d["file_upload"] == {
|
||||
"allowed_file_extensions": ["JPG", "JPEG", "PNG", "GIF", "WEBP", "SVG"],
|
||||
"allowed_file_types": ["document", "image", "audio", "video"],
|
||||
"allowed_file_upload_methods": ["local_file", "remote_url"],
|
||||
"enabled": True,
|
||||
"image": {"enabled": True},
|
||||
"number_limits": 3,
|
||||
}
|
||||
|
||||
|
||||
def test_soul_file_upload_overrides_legacy_app_model_config():
|
||||
fake_amc = SimpleNamespace(
|
||||
to_dict=lambda: {
|
||||
"file_upload": {
|
||||
"enabled": False,
|
||||
"image": {"enabled": False},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
d = AgentAppConfigManager._synthesize_config_dict(AgentSoulConfig(), fake_amc) # type: ignore[arg-type]
|
||||
|
||||
assert d["file_upload"] == {
|
||||
"allowed_file_extensions": ["JPG", "JPEG", "PNG", "GIF", "WEBP", "SVG"],
|
||||
"allowed_file_types": ["document", "image", "audio", "video"],
|
||||
"allowed_file_upload_methods": ["local_file", "remote_url"],
|
||||
"enabled": True,
|
||||
"image": {"enabled": True},
|
||||
"number_limits": 3,
|
||||
}
|
||||
|
||||
|
||||
def test_prompt_type_defaults_to_simple():
|
||||
|
||||
@@ -568,7 +568,7 @@ def test_successful_turn_persists_thinking_and_tool_process_events(monkeypatch):
|
||||
|
||||
rows = sorted(fake_session.rows.values(), key=lambda row: row.position)
|
||||
assert rows[0].thought == "I need to inspect the file."
|
||||
assert rows[0].tool is None
|
||||
assert rows[0].tool == ""
|
||||
assert rows[1].tool == "bash"
|
||||
assert rows[1].tool_input == '{"cmd": "ls"}'
|
||||
assert rows[1].observation == "ok"
|
||||
@@ -656,9 +656,9 @@ def test_tool_result_without_identity_does_not_attach_to_previous_tool(monkeypat
|
||||
assert len(rows) == 2
|
||||
assert rows[0].tool == "shell_run"
|
||||
assert rows[0].tool_input == '{"script": "npx skills find browser"}'
|
||||
assert rows[0].observation is None
|
||||
assert rows[1].tool is None
|
||||
assert rows[1].tool_input is None
|
||||
assert rows[0].observation == ""
|
||||
assert rows[1].tool == ""
|
||||
assert rows[1].tool_input == ""
|
||||
assert rows[1].observation == "Knowledge base search results: browser skill"
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ from typing import Any
|
||||
import pytest
|
||||
|
||||
from core.app.apps.agent_app import app_generator as gen_mod
|
||||
from core.app.apps.agent_app.app_generator import AgentAppGenerator, AgentAppGeneratorError
|
||||
from core.app.apps.agent_app.app_generator import AgentAppGenerator, AgentAppGeneratorError, AgentAppNotPublishedError
|
||||
from core.app.entities.app_invoke_entities import InvokeFrom
|
||||
|
||||
_SOUL_DICT = {
|
||||
@@ -78,7 +78,7 @@ class TestResolveAgentById:
|
||||
|
||||
class TestResolveAgent:
|
||||
def test_success_chains_to_resolve_by_id(self, monkeypatch: pytest.MonkeyPatch):
|
||||
bound_agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="snap-1")
|
||||
bound_agent = SimpleNamespace(id="agent-1", active_config_snapshot_id="snap-1", active_config_is_published=True)
|
||||
inner_agent = SimpleNamespace(id="agent-1")
|
||||
snapshot = _snapshot()
|
||||
# scalar order: bound agent (in _resolve_agent), then agent + snapshot (in _resolve_agent_by_id)
|
||||
@@ -97,6 +97,23 @@ class TestResolveAgent:
|
||||
assert config_version_kind == "snapshot"
|
||||
assert soul.model is not None
|
||||
|
||||
def test_unpublished_agent_raises_before_model_resolution(self, monkeypatch: pytest.MonkeyPatch):
|
||||
bound_agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
active_config_snapshot_id="snap-1",
|
||||
active_config_is_published=False,
|
||||
)
|
||||
_patch_session(monkeypatch, [bound_agent])
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="t1")
|
||||
|
||||
with pytest.raises(AgentAppNotPublishedError, match="not been published"):
|
||||
AgentAppGenerator()._resolve_agent(
|
||||
app_model,
|
||||
invoke_from=InvokeFrom.WEB_APP,
|
||||
draft_type=None,
|
||||
user=SimpleNamespace(id="user-1"),
|
||||
) # type: ignore[arg-type]
|
||||
|
||||
def test_unbound_app_raises(self, monkeypatch: pytest.MonkeyPatch):
|
||||
_patch_session(monkeypatch, [None])
|
||||
app_model = SimpleNamespace(id="app-1", tenant_id="t1")
|
||||
|
||||
+52
-2
@@ -721,6 +721,56 @@ class TestEasyUiBasedGenerateTaskPipeline:
|
||||
assert response is not None
|
||||
assert response.id == "thought"
|
||||
|
||||
def test_agent_thought_to_stream_response_normalizes_null_display_fields(self, monkeypatch: pytest.MonkeyPatch):
|
||||
conversation = _make_conversation(AppMode.CHAT)
|
||||
message = _make_message()
|
||||
|
||||
pipeline = EasyUIBasedGenerateTaskPipeline(
|
||||
application_generate_entity=_make_entity(ChatAppGenerateEntity, AppMode.CHAT),
|
||||
queue_manager=_FakeQueueManager(),
|
||||
conversation=conversation,
|
||||
message=message,
|
||||
stream=True,
|
||||
)
|
||||
|
||||
agent_thought = _agent_thought()
|
||||
agent_thought.thought = None
|
||||
agent_thought.observation = None
|
||||
agent_thought.tool = None
|
||||
agent_thought.tool_input = None
|
||||
agent_thought.message_files = None
|
||||
|
||||
class _Session:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def scalar(self, *args, **kwargs):
|
||||
return agent_thought
|
||||
|
||||
monkeypatch.setattr(
|
||||
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.Session",
|
||||
_Session,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db",
|
||||
_FakeDb(),
|
||||
)
|
||||
|
||||
response = pipeline._agent_thought_to_stream_response(QueueAgentThoughtEvent(agent_thought_id="thought"))
|
||||
|
||||
assert response is not None
|
||||
assert response.thought == ""
|
||||
assert response.observation == ""
|
||||
assert response.tool == ""
|
||||
assert response.tool_input == ""
|
||||
assert response.model_dump(mode="json")["message_files"] == []
|
||||
|
||||
def test_process_routes_to_stream_and_starts_conversation_name_generation(self):
|
||||
conversation = _make_conversation(AppMode.CHAT)
|
||||
message = _make_message()
|
||||
@@ -1280,7 +1330,7 @@ class TestEasyUiBasedGenerateTaskPipeline:
|
||||
usage_metadata = cast(dict[str, object], response.metadata["usage"])
|
||||
assert usage_metadata["prompt_tokens"] == 1
|
||||
|
||||
def test_record_files_returns_none_when_message_has_no_files(self, monkeypatch: pytest.MonkeyPatch):
|
||||
def test_record_files_returns_empty_list_when_message_has_no_files(self, monkeypatch: pytest.MonkeyPatch):
|
||||
conversation = _make_conversation(AppMode.CHAT)
|
||||
message = _make_message()
|
||||
pipeline = EasyUIBasedGenerateTaskPipeline(
|
||||
@@ -1316,7 +1366,7 @@ class TestEasyUiBasedGenerateTaskPipeline:
|
||||
|
||||
response = pipeline._message_end_to_stream_response()
|
||||
|
||||
assert response.files is None
|
||||
assert response.files == []
|
||||
|
||||
def test_record_files_handles_local_fallback_and_tool_url_variants(self, monkeypatch: pytest.MonkeyPatch):
|
||||
conversation = _make_conversation(AppMode.CHAT)
|
||||
|
||||
@@ -6,7 +6,7 @@ SSE event, which is critical for vision/image chat responses to render correctly
|
||||
|
||||
Test Coverage:
|
||||
- Files array populated when MessageFile records exist
|
||||
- Files array is None when no MessageFile records exist
|
||||
- Files array is empty when no MessageFile records exist
|
||||
- Correct signed URL generation for LOCAL_FILE transfer method
|
||||
- Correct URL handling for REMOTE_URL transfer method
|
||||
- Correct URL handling for TOOL_FILE transfer method
|
||||
@@ -90,7 +90,7 @@ class TestMessageEndStreamResponseFiles:
|
||||
return upload_file
|
||||
|
||||
def test_message_end_with_no_files(self, mock_pipeline):
|
||||
"""Test that files array is None when no MessageFile records exist."""
|
||||
"""Test that files array is empty when no MessageFile records exist."""
|
||||
# Arrange
|
||||
with (
|
||||
patch("core.app.task_pipeline.easy_ui_based_generate_task_pipeline.db") as mock_db,
|
||||
@@ -108,9 +108,10 @@ class TestMessageEndStreamResponseFiles:
|
||||
|
||||
# Assert
|
||||
assert isinstance(result, MessageEndStreamResponse)
|
||||
assert result.files is None
|
||||
assert result.files == []
|
||||
assert result.id == mock_pipeline._message_id
|
||||
assert result.metadata == {"test": "metadata"}
|
||||
mock_pipeline._task_state.metadata.model_dump.assert_called_once_with(exclude_none=True)
|
||||
|
||||
def test_message_end_with_local_file(self, mock_pipeline, mock_message_file_local, mock_upload_file):
|
||||
"""Test that files array is populated correctly for LOCAL_FILE transfer method."""
|
||||
|
||||
@@ -227,6 +227,15 @@ def _previous_node_prompt_payload(result, selector: str) -> object:
|
||||
raise AssertionError(f"missing prompt payload for {selector}")
|
||||
|
||||
|
||||
def _uploaded_workflow_files_prompt_payload(result) -> object:
|
||||
prefix = " - sys.files: "
|
||||
user_prompt = _workflow_user_prompt(result)
|
||||
for line in user_prompt.splitlines():
|
||||
if line.startswith(prefix):
|
||||
return json.loads(line.removeprefix(prefix))
|
||||
raise AssertionError("missing prompt payload for sys.files")
|
||||
|
||||
|
||||
def test_builds_create_run_request_from_agent_soul_and_node_job():
|
||||
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(_context())
|
||||
|
||||
@@ -1252,6 +1261,48 @@ def test_previous_node_file_array_uses_agent_stub_download_mappings_in_workflow_
|
||||
]
|
||||
|
||||
|
||||
def test_uploaded_workflow_files_are_included_without_prompt_marker():
|
||||
file_reference = build_file_reference(record_id="uploaded-file-1")
|
||||
|
||||
class UploadedFilesVariablePool(FakeVariablePool):
|
||||
def get(self, selector):
|
||||
if list(selector) == ["sys", "files"]:
|
||||
return ArrayFileSegment(
|
||||
value=[
|
||||
File(
|
||||
type=FileType.DOCUMENT,
|
||||
transfer_method=FileTransferMethod.LOCAL_FILE,
|
||||
reference=file_reference,
|
||||
remote_url=None,
|
||||
filename="requirements.pdf",
|
||||
extension=".pdf",
|
||||
mime_type="application/pdf",
|
||||
size=12,
|
||||
)
|
||||
]
|
||||
)
|
||||
return super().get(selector)
|
||||
|
||||
context = replace(_context(), variable_pool=UploadedFilesVariablePool())
|
||||
context.binding.node_job_config = WorkflowNodeJobConfig.model_validate(
|
||||
{
|
||||
"workflow_prompt": "Answer the user's question.",
|
||||
}
|
||||
)
|
||||
|
||||
result = WorkflowAgentRuntimeRequestBuilder(credentials_provider=FakeCredentialsProvider()).build(context)
|
||||
|
||||
user_prompt = _workflow_user_prompt(result)
|
||||
assert "- Uploaded workflow files:" in user_prompt
|
||||
assert _uploaded_workflow_files_prompt_payload(result) == [
|
||||
{
|
||||
"transfer_method": "local_file",
|
||||
"reference": file_reference,
|
||||
}
|
||||
]
|
||||
assert "Previous node outputs:" not in user_prompt
|
||||
|
||||
|
||||
def test_previous_node_remote_url_file_mapping_is_not_truncated_in_workflow_context():
|
||||
remote_url = "https://example.com/" + ("a" * 2100) + ".pdf"
|
||||
|
||||
|
||||
@@ -14,6 +14,23 @@ from services.entities.agent_entities import (
|
||||
)
|
||||
|
||||
|
||||
def test_default_agent_soul_enables_file_upload_feature():
|
||||
agent_soul = AgentSoulConfig()
|
||||
|
||||
file_upload = agent_soul.model_dump(mode="json")["app_features"]["file_upload"]
|
||||
assert file_upload == {
|
||||
"allowed_file_extensions": ["JPG", "JPEG", "PNG", "GIF", "WEBP", "SVG"],
|
||||
"allowed_file_types": ["document", "image", "audio", "video"],
|
||||
"allowed_file_upload_methods": ["local_file", "remote_url"],
|
||||
"enabled": True,
|
||||
"image": {"enabled": True},
|
||||
"number_limits": 3,
|
||||
}
|
||||
# The product default should be visible in API responses, but it must not
|
||||
# make workflow-only payload validation treat app_features as user-authored.
|
||||
assert bool(agent_soul.app_features) is False
|
||||
|
||||
|
||||
def test_workflow_variant_rejects_agent_app_only_fields():
|
||||
with pytest.raises(ValueError):
|
||||
ComposerSavePayload.model_validate(
|
||||
@@ -257,6 +274,16 @@ def test_knowledge_query_mode_uses_stable_backend_enums():
|
||||
},
|
||||
"knowledge set dataset ids must be unique",
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_knowledge_sets_contract_rejects_invalid_configs(knowledge_payload, match: str):
|
||||
with pytest.raises(ValidationError, match=match):
|
||||
AgentSoulConfig.model_validate({"knowledge": knowledge_payload})
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("knowledge_payload", "match"),
|
||||
[
|
||||
(
|
||||
{
|
||||
"sets": [
|
||||
@@ -317,9 +344,25 @@ def test_knowledge_query_mode_uses_stable_backend_enums():
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_knowledge_sets_contract_rejects_invalid_configs(knowledge_payload, match: str):
|
||||
with pytest.raises(ValidationError, match=match):
|
||||
AgentSoulConfig.model_validate({"knowledge": knowledge_payload})
|
||||
def test_knowledge_runtime_requirements_block_publish_but_not_draft_save(knowledge_payload, match: str):
|
||||
draft_payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": ComposerVariant.AGENT_APP,
|
||||
"save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION,
|
||||
"agent_soul": {"knowledge": knowledge_payload},
|
||||
}
|
||||
)
|
||||
ComposerConfigValidator.validate_draft_save_payload(draft_payload)
|
||||
|
||||
publish_payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": ComposerVariant.AGENT_APP,
|
||||
"save_strategy": ComposerSaveStrategy.SAVE_AS_NEW_VERSION,
|
||||
"agent_soul": {"knowledge": knowledge_payload},
|
||||
}
|
||||
)
|
||||
with pytest.raises(InvalidComposerConfigError, match=match):
|
||||
ComposerConfigValidator.validate_publish_payload(publish_payload)
|
||||
|
||||
|
||||
def test_agent_soul_model_config_is_first_class_without_credentials():
|
||||
|
||||
@@ -36,6 +36,7 @@ from services.agent.agent_soul_state import agent_soul_has_model
|
||||
from services.agent.composer_service import AgentComposerService
|
||||
from services.agent.composer_validator import ComposerConfigValidator
|
||||
from services.agent.errors import (
|
||||
AgentModelNotConfiguredError,
|
||||
AgentNameConflictError,
|
||||
AgentNotFoundError,
|
||||
AgentVersionConflictError,
|
||||
@@ -576,6 +577,55 @@ def test_save_agent_app_composer_keeps_published_when_draft_matches_active_snaps
|
||||
assert fake_session.commits == 1
|
||||
|
||||
|
||||
def test_publish_agent_app_draft_rejects_missing_model(monkeypatch: pytest.MonkeyPatch):
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
tenant_id="tenant-1",
|
||||
name="Iris",
|
||||
description="",
|
||||
agent_kind=AgentKind.DIFY_AGENT,
|
||||
scope=AgentScope.ROSTER,
|
||||
source=AgentSource.AGENT_APP,
|
||||
status=AgentStatus.ACTIVE,
|
||||
active_config_snapshot_id="version-1",
|
||||
active_config_is_published=False,
|
||||
)
|
||||
draft = AgentConfigDraft(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
draft_type=AgentConfigDraftType.DRAFT,
|
||||
draft_owner_key="",
|
||||
base_snapshot_id="version-1",
|
||||
config_snapshot=AgentSoulConfig(),
|
||||
)
|
||||
fake_session = FakeSession(scalar=[agent, draft])
|
||||
|
||||
def fail_create_config_version(**_kwargs):
|
||||
raise AssertionError("config version must not be created when Agent Soul has no model")
|
||||
|
||||
def fail_validate_knowledge_datasets(**_kwargs):
|
||||
raise AssertionError("knowledge datasets must not be validated when Agent Soul has no model")
|
||||
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", lambda payload: None)
|
||||
monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", fail_validate_knowledge_datasets)
|
||||
monkeypatch.setattr(AgentComposerService, "_create_config_version", fail_create_config_version)
|
||||
|
||||
with pytest.raises(AgentModelNotConfiguredError) as exc_info:
|
||||
AgentComposerService.publish_agent_app_draft(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
account_id="account-1",
|
||||
version_note="ship it",
|
||||
)
|
||||
|
||||
assert exc_info.value.error_code == "agent_model_not_configured"
|
||||
assert agent.active_config_snapshot_id == "version-1"
|
||||
assert agent.active_config_is_published is False
|
||||
assert draft.base_snapshot_id == "version-1"
|
||||
assert fake_session.commits == 0
|
||||
|
||||
|
||||
def test_publish_agent_app_draft_creates_published_snapshot(monkeypatch: pytest.MonkeyPatch):
|
||||
agent = Agent(
|
||||
id="agent-1",
|
||||
@@ -4257,31 +4307,7 @@ def test_dataset_rows_filters_malformed_ids(monkeypatch: pytest.MonkeyPatch):
|
||||
assert captured == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("variant", "save_call"),
|
||||
[
|
||||
(
|
||||
ComposerVariant.AGENT_APP,
|
||||
lambda payload: AgentComposerService.save_agent_app_composer(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
account_id="account-1",
|
||||
payload=payload,
|
||||
),
|
||||
),
|
||||
(
|
||||
ComposerVariant.WORKFLOW,
|
||||
lambda payload: AgentComposerService.save_workflow_composer(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
node_id="node-1",
|
||||
account_id="account-1",
|
||||
payload=payload,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_composer_save_rejects_malformed_knowledge_dataset_ids(monkeypatch: pytest.MonkeyPatch, variant, save_call):
|
||||
def test_validate_knowledge_datasets_rejects_malformed_ids_without_dataset_lookup(monkeypatch: pytest.MonkeyPatch):
|
||||
captured = {"calls": 0}
|
||||
|
||||
def fake_get_datasets_by_ids(ids, tenant_id):
|
||||
@@ -4294,60 +4320,29 @@ def test_composer_save_rejects_malformed_knowledge_dataset_ids(monkeypatch: pyte
|
||||
|
||||
monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids)
|
||||
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
agent_soul = AgentSoulConfig.model_validate(
|
||||
{
|
||||
"variant": variant.value,
|
||||
"save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
|
||||
"soul_lock": {"locked": False},
|
||||
"agent_soul": {
|
||||
"knowledge": {
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "not-a-uuid"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
]
|
||||
}
|
||||
"knowledge": {
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": "not-a-uuid"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidComposerConfigError, match="not-a-uuid"):
|
||||
save_call(payload)
|
||||
AgentComposerService.validate_knowledge_datasets(tenant_id="tenant-1", agent_soul=agent_soul)
|
||||
|
||||
assert captured == {"calls": 0}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("variant", "save_call"),
|
||||
[
|
||||
(
|
||||
ComposerVariant.AGENT_APP,
|
||||
lambda payload: AgentComposerService.save_agent_app_composer(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
account_id="account-1",
|
||||
payload=payload,
|
||||
),
|
||||
),
|
||||
(
|
||||
ComposerVariant.WORKFLOW,
|
||||
lambda payload: AgentComposerService.save_workflow_composer(
|
||||
tenant_id="tenant-1",
|
||||
app_id="app-1",
|
||||
node_id="node-1",
|
||||
account_id="account-1",
|
||||
payload=payload,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_composer_save_rejects_missing_or_out_of_scope_knowledge_datasets(
|
||||
monkeypatch: pytest.MonkeyPatch, variant, save_call
|
||||
):
|
||||
def test_validate_knowledge_datasets_rejects_missing_or_out_of_scope_datasets(monkeypatch: pytest.MonkeyPatch):
|
||||
captured = {}
|
||||
missing_dataset_id = "550e8400-e29b-41d4-a716-446655440000"
|
||||
|
||||
@@ -4360,20 +4355,70 @@ def test_composer_save_rejects_missing_or_out_of_scope_knowledge_datasets(
|
||||
|
||||
monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids)
|
||||
|
||||
agent_soul = AgentSoulConfig.model_validate(
|
||||
{
|
||||
"knowledge": {
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": missing_dataset_id}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidComposerConfigError, match=missing_dataset_id):
|
||||
AgentComposerService.validate_knowledge_datasets(tenant_id="tenant-1", agent_soul=agent_soul)
|
||||
|
||||
assert captured == {"ids": [missing_dataset_id], "tenant_id": "tenant-1"}
|
||||
|
||||
|
||||
def test_save_agent_composer_allows_incomplete_knowledge_draft(monkeypatch: pytest.MonkeyPatch):
|
||||
agent = SimpleNamespace(
|
||||
id="agent-1",
|
||||
source=AgentSource.AGENT_APP,
|
||||
active_config_snapshot_id="version-1",
|
||||
active_config_is_published=True,
|
||||
updated_by=None,
|
||||
)
|
||||
active_version = SimpleNamespace(config_snapshot_dict=AgentSoulConfig().model_dump(mode="json"))
|
||||
fake_session = FakeSession(scalar=[agent])
|
||||
saved = {}
|
||||
|
||||
import services.dataset_service as dataset_service_module
|
||||
|
||||
monkeypatch.setattr(composer_service.db, "session", fake_session)
|
||||
monkeypatch.setattr(
|
||||
dataset_service_module.DatasetService,
|
||||
"get_datasets_by_ids",
|
||||
lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("draft save must skip dataset lookup")),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
AgentComposerService,
|
||||
"_save_agent_draft",
|
||||
lambda **kwargs: saved.update(kwargs) or SimpleNamespace(id="draft-1"),
|
||||
)
|
||||
monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **_kwargs: active_version)
|
||||
monkeypatch.setattr(AgentComposerService, "load_agent_composer", lambda **_kwargs: {"loaded": True})
|
||||
|
||||
payload = ComposerSavePayload.model_validate(
|
||||
{
|
||||
"variant": variant.value,
|
||||
"variant": ComposerVariant.AGENT_APP.value,
|
||||
"save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value,
|
||||
"soul_lock": {"locked": False},
|
||||
"agent_soul": {
|
||||
"knowledge": {
|
||||
"sets": [
|
||||
{
|
||||
"id": "support",
|
||||
"name": "Support KB",
|
||||
"datasets": [{"id": missing_dataset_id}],
|
||||
"datasets": [{"id": "not-a-uuid"}],
|
||||
"query": {"mode": "generated_query"},
|
||||
"retrieval": {"mode": "multiple", "top_k": 4},
|
||||
"retrieval": {"mode": "single"},
|
||||
"metadata_filtering": {"mode": "automatic"},
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4381,10 +4426,20 @@ def test_composer_save_rejects_missing_or_out_of_scope_knowledge_datasets(
|
||||
}
|
||||
)
|
||||
|
||||
with pytest.raises(InvalidComposerConfigError, match=missing_dataset_id):
|
||||
save_call(payload)
|
||||
result = AgentComposerService.save_agent_composer(
|
||||
tenant_id="tenant-1",
|
||||
agent_id="agent-1",
|
||||
account_id="account-1",
|
||||
payload=payload,
|
||||
)
|
||||
|
||||
assert captured == {"ids": [missing_dataset_id], "tenant_id": "tenant-1"}
|
||||
assert result["loaded"] is True
|
||||
assert saved["draft_type"] == AgentConfigDraftType.DRAFT
|
||||
assert saved["agent_soul"].knowledge.sets[0].retrieval.mode == "single"
|
||||
assert saved["agent_soul"].knowledge.sets[0].retrieval.model is None
|
||||
assert saved["agent_soul"].knowledge.sets[0].metadata_filtering.mode == "automatic"
|
||||
assert saved["agent_soul"].knowledge.sets[0].metadata_filtering.metadata_model_config is None
|
||||
assert fake_session.commits == 1
|
||||
|
||||
|
||||
def test_workspace_dify_tools_returns_provider_and_tool_granularities(monkeypatch: pytest.MonkeyPatch):
|
||||
|
||||
@@ -18,8 +18,10 @@ from models.agent import AgentRuntimeSession, AgentRuntimeSessionOwnerType, Agen
|
||||
from services.agent_app_sandbox_service import (
|
||||
AgentAppSandboxService,
|
||||
AgentSandboxInspectorError,
|
||||
AgentSandboxUploadDownload,
|
||||
WorkflowAgentSandboxService,
|
||||
_default_client_factory,
|
||||
_upload_download_response,
|
||||
)
|
||||
|
||||
|
||||
@@ -129,6 +131,30 @@ def test_agent_app_sandbox_service_builds_locator_and_proxies() -> None:
|
||||
assert store.scope == ("tenant-1", "app-1", "conv-1")
|
||||
|
||||
|
||||
def test_agent_app_sandbox_service_upload_returns_download_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
store = FakeStore(_stored_session())
|
||||
client = FakeClient()
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_upload_download_response(*, tenant_id: str, file_mapping: dict[str, object]) -> AgentSandboxUploadDownload:
|
||||
captured["tenant_id"] = tenant_id
|
||||
captured["file_mapping"] = file_mapping
|
||||
return AgentSandboxUploadDownload(url="https://files.example/report.txt?token=1&as_attachment=true")
|
||||
|
||||
monkeypatch.setattr("services.agent_app_sandbox_service._upload_download_response", fake_upload_download_response)
|
||||
service = AgentAppSandboxService(session_store=store, client_factory=lambda: client) # type: ignore[arg-type]
|
||||
|
||||
result = service.upload_file(tenant_id="tenant-1", app_id="app-1", conversation_id="conv-1", path="report.txt")
|
||||
|
||||
assert result.url == "https://files.example/report.txt?token=1&as_attachment=true"
|
||||
assert client.calls == [("upload", "report.txt")]
|
||||
assert store.scope == ("tenant-1", "app-1", "conv-1")
|
||||
assert captured == {
|
||||
"tenant_id": "tenant-1",
|
||||
"file_mapping": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"},
|
||||
}
|
||||
|
||||
|
||||
def test_agent_app_sandbox_service_raises_when_no_active_session() -> None:
|
||||
service = AgentAppSandboxService(session_store=FakeStore(None), client_factory=lambda: FakeClient()) # type: ignore[arg-type]
|
||||
|
||||
@@ -210,9 +236,19 @@ def _insert_workflow_session(
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("_runtime_session_table")
|
||||
def test_workflow_sandbox_service_resolves_locator_and_proxies() -> None:
|
||||
def test_workflow_sandbox_service_resolves_locator_and_returns_download_url(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
_insert_workflow_session()
|
||||
client = FakeClient()
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
def fake_upload_download_response(*, tenant_id: str, file_mapping: dict[str, object]) -> AgentSandboxUploadDownload:
|
||||
captured["tenant_id"] = tenant_id
|
||||
captured["file_mapping"] = file_mapping
|
||||
return AgentSandboxUploadDownload(url="https://files.example/report.txt?token=1&as_attachment=true")
|
||||
|
||||
monkeypatch.setattr("services.agent_app_sandbox_service._upload_download_response", fake_upload_download_response)
|
||||
service = WorkflowAgentSandboxService(client_factory=lambda: client) # type: ignore[arg-type]
|
||||
|
||||
result = service.upload_file(
|
||||
@@ -224,8 +260,96 @@ def test_workflow_sandbox_service_resolves_locator_and_proxies() -> None:
|
||||
path="report.txt",
|
||||
)
|
||||
|
||||
assert result.file.reference == "dify-file-ref:file-1"
|
||||
assert result.url == "https://files.example/report.txt?token=1&as_attachment=true"
|
||||
assert client.calls == [("upload", "report.txt")]
|
||||
assert captured == {
|
||||
"tenant_id": "tenant-1",
|
||||
"file_mapping": {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"},
|
||||
}
|
||||
|
||||
|
||||
def test_upload_download_response_resolves_signed_external_url(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
built_file = object()
|
||||
built_with: dict[str, object] = {}
|
||||
|
||||
def fake_build_from_mapping(*, mapping: dict[str, object], tenant_id: str, access_controller: object) -> object:
|
||||
built_with["mapping"] = mapping
|
||||
built_with["tenant_id"] = tenant_id
|
||||
built_with["access_controller"] = access_controller
|
||||
return built_file
|
||||
|
||||
class FakeRuntime:
|
||||
def __init__(self, *, file_access_controller: object) -> None:
|
||||
self.file_access_controller = file_access_controller
|
||||
|
||||
def resolve_file_url(self, *, file: object, for_external: bool) -> str:
|
||||
assert file is built_file
|
||||
assert for_external is True
|
||||
return "https://files.example/files/tools/tool-file.txt?timestamp=1&nonce=2&sign=3"
|
||||
|
||||
monkeypatch.setattr("services.agent_app_sandbox_service.file_factory.build_from_mapping", fake_build_from_mapping)
|
||||
monkeypatch.setattr("services.agent_app_sandbox_service.DifyWorkflowFileRuntime", FakeRuntime)
|
||||
|
||||
result = _upload_download_response(
|
||||
tenant_id="tenant-1",
|
||||
file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"},
|
||||
)
|
||||
|
||||
assert result.url == (
|
||||
"https://files.example/files/tools/tool-file.txt?timestamp=1&nonce=2&sign=3&as_attachment=true"
|
||||
)
|
||||
assert built_with["mapping"] == {"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"}
|
||||
assert built_with["tenant_id"] == "tenant-1"
|
||||
assert built_with["access_controller"] is not None
|
||||
|
||||
|
||||
def test_upload_download_response_maps_resolution_failure_to_inspector_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
def fake_build_from_mapping(*, mapping: dict[str, object], tenant_id: str, access_controller: object) -> object:
|
||||
del mapping, tenant_id, access_controller
|
||||
raise ValueError("missing tool file")
|
||||
|
||||
monkeypatch.setattr("services.agent_app_sandbox_service.file_factory.build_from_mapping", fake_build_from_mapping)
|
||||
|
||||
with pytest.raises(AgentSandboxInspectorError) as exc_info:
|
||||
_upload_download_response(
|
||||
tenant_id="tenant-1",
|
||||
file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"},
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "sandbox_upload_download_unavailable"
|
||||
assert exc_info.value.status_code == 502
|
||||
|
||||
|
||||
def test_upload_download_response_maps_missing_url_to_inspector_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
built_file = object()
|
||||
|
||||
def fake_build_from_mapping(*, mapping: dict[str, object], tenant_id: str, access_controller: object) -> object:
|
||||
del mapping, tenant_id, access_controller
|
||||
return built_file
|
||||
|
||||
class FakeRuntime:
|
||||
def __init__(self, *, file_access_controller: object) -> None:
|
||||
self.file_access_controller = file_access_controller
|
||||
|
||||
def resolve_file_url(self, *, file: object, for_external: bool) -> None:
|
||||
assert file is built_file
|
||||
assert for_external is True
|
||||
|
||||
monkeypatch.setattr("services.agent_app_sandbox_service.file_factory.build_from_mapping", fake_build_from_mapping)
|
||||
monkeypatch.setattr("services.agent_app_sandbox_service.DifyWorkflowFileRuntime", FakeRuntime)
|
||||
|
||||
with pytest.raises(AgentSandboxInspectorError) as exc_info:
|
||||
_upload_download_response(
|
||||
tenant_id="tenant-1",
|
||||
file_mapping={"transfer_method": "tool_file", "reference": "dify-file-ref:file-1"},
|
||||
)
|
||||
|
||||
assert exc_info.value.code == "sandbox_upload_download_unavailable"
|
||||
assert exc_info.value.status_code == 502
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("_runtime_session_table")
|
||||
|
||||
Reference in New Issue
Block a user