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:
yyh
2026-07-06 13:51:33 +00:00
committed by GitHub
co-authored by Joel zyssyz123 autofix-ci[bot] 林玮 盐粒 Yanli
parent bdb3469ca0
commit d0ea5a5e0d
180 changed files with 4929 additions and 1512 deletions
@@ -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")
@@ -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")