import json from contextlib import nullcontext from datetime import UTC, datetime from types import SimpleNamespace from unittest.mock import MagicMock, call import pytest from sqlalchemy import event, select from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from core.workflow.nodes.agent_v2.validators import WorkflowAgentNodeValidationError from models.account import Account from models.agent import ( Agent, AgentConfigDraft, AgentConfigDraftType, AgentConfigRevision, AgentConfigRevisionOperation, AgentConfigSnapshot, AgentConfigVersionKind, AgentDebugConversation, AgentDriveFile, AgentDriveFileKind, AgentHomeSnapshot, AgentKind, AgentScope, AgentSource, AgentStatus, AgentWorkspaceBinding, AgentWorkspaceOwnerType, WorkflowAgentBindingType, WorkflowAgentNodeBinding, ) from models.agent_config_entities import ( DeclaredArrayItem, DeclaredOutputChildConfig, DeclaredOutputConfig, DeclaredOutputType, WorkflowNodeJobConfig, ) from models.enums import AppStatus, ConversationFromSource, ConversationStatus from models.model import App, AppMode, Conversation, IconType, Message from models.workflow import Workflow, WorkflowType from services.agent import composer_service, roster_service 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 ( AgentBuildSandboxNotFoundError, AgentModelNotConfiguredError, AgentNameConflictError, AgentNotFoundError, AgentVersionConflictError, AgentVersionNotFoundError, InvalidComposerConfigError, ) from services.agent.home_snapshot_service import AgentHomeSnapshotService from services.agent.roster_service import AgentRosterService from services.agent.workflow_publish_service import WorkflowAgentPublishService from services.agent.workspace_service import AgentWorkspaceService from services.app_service import AppListParams, AppService from services.entities.agent_entities import AgentSoulConfig, ComposerSavePayload, ComposerSaveStrategy, ComposerVariant def _agent_soul_with_model() -> AgentSoulConfig: return AgentSoulConfig.model_validate( { "model": { "plugin_id": "langgenius/openai/openai", "model_provider": "openai", "model": "gpt-4o", } } ) def _agent( *, agent_id: str = "agent-1", tenant_id: str = "tenant-1", name: str = "Researcher", scope: AgentScope = AgentScope.ROSTER, source: AgentSource = AgentSource.ROSTER, app_id: str | None = None, ) -> Agent: return Agent( id=agent_id, tenant_id=tenant_id, name=name, description="desc", role="assistant", agent_kind=AgentKind.DIFY_AGENT, scope=scope, source=source, app_id=app_id, status=AgentStatus.ACTIVE, created_by="account-1", updated_by="account-1", ) def _snapshot( *, snapshot_id: str = "snapshot-1", tenant_id: str = "tenant-1", agent_id: str = "agent-1", version: int = 1, agent_soul: AgentSoulConfig | None = None, ) -> AgentConfigSnapshot: return AgentConfigSnapshot( id=snapshot_id, tenant_id=tenant_id, agent_id=agent_id, version=version, config_snapshot=agent_soul or _agent_soul_with_model(), created_by="account-1", ) def _conversation(*, conversation_id: str = "conversation-1", account_id: str = "account-1") -> Conversation: return Conversation( id=conversation_id, app_id="app-1", override_model_configs="{}", mode=AppMode.AGENT_CHAT, name="Debug", summary="", _inputs={}, introduction="", system_instruction="", status=ConversationStatus.NORMAL, from_source=ConversationFromSource.CONSOLE, from_account_id=account_id, dialogue_count=0, ) def _workflow(*, workflow_id: str = "workflow-1", tenant_id: str = "tenant-1", app_id: str = "app-1") -> Workflow: return Workflow( id=workflow_id, tenant_id=tenant_id, app_id=app_id, type=WorkflowType.WORKFLOW, version=Workflow.VERSION_DRAFT, graph='{"nodes": [], "edges": []}', _features="{}", created_by="account-1", _environment_variables="{}", _conversation_variables="{}", _rag_pipeline_variables="{}", ) def _app( *, app_id: str = "app-1", tenant_id: str = "tenant-1", name: str = "Agent App", mode: AppMode = AppMode.AGENT_CHAT, ) -> App: return App( id=app_id, tenant_id=tenant_id, name=name, description="", mode=mode, icon_type=IconType.EMOJI, icon="🤖", icon_background="#fff", status=AppStatus.NORMAL, enable_site=False, enable_api=True, max_active_requests=None, created_by="account-1", ) def test_agent_soul_has_model(): assert agent_soul_has_model(_agent_soul_with_model()) is True assert agent_soul_has_model(AgentSoulConfig()) is False def test_get_published_agent_soul_for_app_uses_active_snapshot(sqlite_session: Session): agent_soul = AgentSoulConfig.model_validate({"app_features": {"speech_to_text": {"enabled": True}}}) session = sqlite_session agent = _agent(source=AgentSource.AGENT_APP, app_id="app-1") version = _snapshot(snapshot_id="version-1", agent_soul=agent_soul) agent.active_config_snapshot_id = version.id session.add_all([agent, version]) session.commit() result = AgentRosterService(session).get_published_agent_soul_for_app(tenant_id="tenant-1", app_id="app-1") assert result == agent_soul def test_get_published_agent_soul_for_app_returns_none_without_backing_agent(sqlite_session: Session): service = AgentRosterService(sqlite_session) result = service.get_published_agent_soul_for_app(tenant_id="tenant-1", app_id="legacy-app-1") assert result is None def test_peek_authz_app_id_uses_the_parent_app_not_the_hidden_backing_app(sqlite_session: Session): """A workflow-only Agent is authorized against its parent workflow App.""" session = sqlite_session agent = _agent( scope=AgentScope.WORKFLOW_ONLY, source=AgentSource.WORKFLOW, app_id="parent-app-1", ) agent.backing_app_id = "backing-app-1" agent.workflow_id = "workflow-1" agent.workflow_node_id = "node-1" session.add(agent) session.commit() result = AgentRosterService(session).peek_authz_app_id(tenant_id="tenant-1", agent_id="agent-1") assert result == "parent-app-1" def test_peek_authz_app_id_uses_the_roster_agent_app(sqlite_session: Session): session = sqlite_session agent = _agent(source=AgentSource.AGENT_APP, app_id="roster-app-1") session.add(agent) session.commit() result = AgentRosterService(session).peek_authz_app_id(tenant_id="tenant-1", agent_id="agent-1") assert result == "roster-app-1" def test_peek_authz_app_id_returns_none_without_creating_a_backing_app(sqlite_session: Session): """Authorization checks must not materialize the hidden backing App.""" session = sqlite_session service = AgentRosterService(session) result = service.peek_authz_app_id(tenant_id="tenant-1", agent_id="agent-1") assert result is None assert not session.new assert not session.dirty def test_load_workflow_composer_returns_empty_state(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): session = sqlite_session monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: None) result = AgentComposerService.load_workflow_composer( session=session, tenant_id="tenant-1", app_id="app-1", node_id="node-1" ) assert result["binding"] is None assert result["save_options"] == ["node_job_only", "save_to_roster"] assert result["workflow_id"] == "workflow-1" # Stage 4 §4.1 / §10.1 (D-3): empty state still surfaces PRD defaults so # the front-end has stable output names to render before the user declares # anything. effective = result["effective_declared_outputs"] assert [o["name"] for o in effective] == ["text", "files", "json"] files_output = next(o for o in effective if o["name"] == "files") assert files_output["array_item"] == {"type": "file", "description": None, "children": []} def test_load_workflow_composer_serializes_existing_binding(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): session = sqlite_session binding = SimpleNamespace( agent_id="agent-1", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, current_snapshot_id="version-1", ) monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: binding) monkeypatch.setattr( AgentComposerService, "_get_agent_if_present", lambda **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1"), ) monkeypatch.setattr( AgentComposerService, "_get_version_if_present", lambda **kwargs: SimpleNamespace(id="version-1"), ) monkeypatch.setattr( AgentComposerService, "_serialize_workflow_state", lambda **kwargs: {"agent": kwargs["agent"].id, "version": kwargs["version"].id}, ) result = AgentComposerService.load_workflow_composer( session=session, tenant_id="tenant-1", app_id="app-1", node_id="node-1" ) assert result == {"agent": "agent-1", "version": "version-1"} def test_load_workflow_composer_uses_roster_preview_snapshot(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): session = sqlite_session binding = SimpleNamespace( agent_id="agent-1", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, current_snapshot_id="binding-version", ) agent = SimpleNamespace(id="agent-1", scope=AgentScope.ROSTER, active_config_snapshot_id="active-version") monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: binding) monkeypatch.setattr(AgentComposerService, "_get_agent_if_present", lambda **kwargs: agent) monkeypatch.setattr( AgentComposerService, "_require_version", lambda **kwargs: SimpleNamespace(id=kwargs["version_id"]), ) monkeypatch.setattr( AgentComposerService, "_serialize_workflow_state", lambda **kwargs: { "binding_snapshot_id": kwargs["binding"].current_snapshot_id, "version": kwargs["version"].id, }, ) result = AgentComposerService.load_workflow_composer( session=session, tenant_id="tenant-1", app_id="app-1", node_id="node-1", snapshot_id="preview-version", ) assert result == {"binding_snapshot_id": "binding-version", "version": "preview-version"} def test_load_workflow_composer_uses_inline_preview_snapshot(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): session = sqlite_session binding = SimpleNamespace( agent_id="inline-agent-1", binding_type=WorkflowAgentBindingType.INLINE_AGENT, current_snapshot_id="inline-version-1", app_id="app-1", workflow_id="workflow-1", node_id="node-1", ) agent = SimpleNamespace( id="inline-agent-1", scope=AgentScope.WORKFLOW_ONLY, app_id="app-1", workflow_id="workflow-1", workflow_node_id="node-1", active_config_snapshot_id="inline-version-1", ) monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: binding) monkeypatch.setattr(AgentComposerService, "_get_agent_if_present", lambda **kwargs: agent) monkeypatch.setattr( AgentComposerService, "_require_version", lambda **kwargs: SimpleNamespace(id=kwargs["version_id"]), ) monkeypatch.setattr( AgentComposerService, "_serialize_workflow_state", lambda **kwargs: {"agent": kwargs["agent"].id, "version": kwargs["version"].id}, ) result = AgentComposerService.load_workflow_composer( session=session, tenant_id="tenant-1", app_id="app-1", node_id="node-1", snapshot_id="inline-preview-version", ) assert result == {"agent": "inline-agent-1", "version": "inline-preview-version"} def test_workflow_inline_debug_conversation_seed(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): session = sqlite_session captured: dict[str, object] = {} class FakeRosterService: def __init__(self, session): captured["session"] = session def get_or_create_build_conversation(self, **kwargs): captured.update(kwargs) return "debug-conversation-1" monkeypatch.setattr(roster_service, "AgentRosterService", FakeRosterService) binding = SimpleNamespace(binding_type=WorkflowAgentBindingType.INLINE_AGENT) agent = SimpleNamespace(id="inline-agent-1", scope=AgentScope.WORKFLOW_ONLY) debug_conversation_id = AgentComposerService._workflow_inline_debug_conversation_id( session=session, tenant_id="tenant-1", binding=binding, agent=agent, account_id="account-1", ) assert debug_conversation_id == "debug-conversation-1" assert captured["tenant_id"] == "tenant-1" assert captured["agent_id"] == "inline-agent-1" assert captured["account_id"] == "account-1" assert captured["commit"] is False def test_workflow_inline_debug_conversation_seed_skips_non_inline( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): session = sqlite_session class UnexpectedRosterService: def __init__(self, session): raise AssertionError("roster service should not be used") monkeypatch.setattr(roster_service, "AgentRosterService", UnexpectedRosterService) assert ( AgentComposerService._workflow_inline_debug_conversation_id( session=session, tenant_id="tenant-1", binding=SimpleNamespace(binding_type=WorkflowAgentBindingType.ROSTER_AGENT), agent=SimpleNamespace(id="agent-1", scope=AgentScope.ROSTER), account_id="account-1", ) is None ) assert ( AgentComposerService._workflow_inline_debug_conversation_id( session=session, tenant_id="tenant-1", binding=SimpleNamespace(binding_type=WorkflowAgentBindingType.INLINE_AGENT), agent=SimpleNamespace(id="inline-agent-1", scope=AgentScope.WORKFLOW_ONLY), account_id=None, ) is None ) def test_load_workflow_composer_rejects_preview_without_binding( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): session = sqlite_session monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: None) with pytest.raises(AgentVersionNotFoundError): AgentComposerService.load_workflow_composer( session=session, tenant_id="tenant-1", app_id="app-1", node_id="node-1", snapshot_id="preview-version", ) @pytest.mark.parametrize( ("strategy", "helper_name"), [ (ComposerSaveStrategy.NODE_JOB_ONLY, "_save_node_job_only"), (ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION, "_save_to_current_version"), (ComposerSaveStrategy.SAVE_AS_NEW_VERSION, "_save_as_new_version"), (ComposerSaveStrategy.SAVE_AS_NEW_AGENT, "_save_as_new_agent"), (ComposerSaveStrategy.SAVE_TO_ROSTER, "_save_to_roster"), ], ) def test_save_workflow_composer_dispatches_save_strategy(monkeypatch, strategy, helper_name, sqlite_session: Session): session = sqlite_session binding = SimpleNamespace( agent_id="agent-1", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, current_snapshot_id="version-1", ) calls = [] serialize_calls = [] monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None) monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: None) monkeypatch.setattr( AgentComposerService, "_get_agent_if_present", lambda **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1"), ) monkeypatch.setattr( AgentComposerService, "_get_version_if_present", lambda **kwargs: SimpleNamespace(id="version-1"), ) def serialize_workflow_state(**kwargs): serialize_calls.append(kwargs) return {"state": "ok"} monkeypatch.setattr(AgentComposerService, "_serialize_workflow_state", serialize_workflow_state) def save_helper(**kwargs): calls.append(kwargs) return binding monkeypatch.setattr(AgentComposerService, helper_name, save_helper) payload = ComposerSavePayload.model_validate( { "variant": ComposerVariant.WORKFLOW.value, "save_strategy": strategy.value, "agent_soul": {"prompt": {"system_prompt": "x"}}, } ) result = AgentComposerService.save_workflow_composer( session=session, tenant_id="tenant-1", app_id="app-1", node_id="node-1", account_id="account-1", payload=payload, ) assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []} assert result == {"state": "ok"} assert calls assert serialize_calls[0]["account_id"] == "account-1" def test_save_workflow_composer_commits_before_retiring_replaced_inline_agent( monkeypatch, sqlite_session: Session ) -> None: session = sqlite_session events: list[str] = [] old_binding = SimpleNamespace( agent_id="old-inline-agent", binding_type=WorkflowAgentBindingType.INLINE_AGENT, ) new_binding = SimpleNamespace( agent_id="new-agent", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, current_snapshot_id="version-1", ) monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **_kwargs: SimpleNamespace(id="workflow-1")) monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **_kwargs: old_binding) monkeypatch.setattr(AgentComposerService, "_save_as_new_agent", lambda **_kwargs: new_binding) monkeypatch.setattr( AgentComposerService, "_get_agent_if_present", lambda **_kwargs: SimpleNamespace(id="new-agent", active_config_snapshot_id="version-1"), ) monkeypatch.setattr( AgentComposerService, "_get_version_if_present", lambda **_kwargs: SimpleNamespace(id="version-1"), ) monkeypatch.setattr(AgentComposerService, "_serialize_workflow_state", lambda **_kwargs: {"state": "ok"}) monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", lambda **_kwargs: None) monkeypatch.setattr(AgentComposerService, "collect_validation_findings", lambda **_kwargs: {}) event.listen(session, "after_commit", lambda _session: events.append("commit")) def retire_unowned(**kwargs): assert kwargs["agent_ids"] == {"old-inline-agent"} events.append("retire") return ["binding-1"], ["home-1"] monkeypatch.setattr(composer_service.WorkflowAgentRetirementService, "retire_unowned", retire_unowned) monkeypatch.setattr( composer_service, "enqueue_agent_resource_collection", MagicMock(side_effect=lambda **_kwargs: events.append("enqueue")), ) payload = ComposerSavePayload.model_validate( { "variant": ComposerVariant.WORKFLOW, "save_strategy": ComposerSaveStrategy.SAVE_AS_NEW_AGENT, "agent_soul": _agent_soul_with_model().model_dump(mode="json"), "new_agent_name": "New Agent", "soul_lock": {"locked": False}, } ) AgentComposerService.save_workflow_composer( session=session, tenant_id="tenant-1", app_id="app-1", node_id="node-1", account_id="account-1", payload=payload, ) assert events == ["commit", "retire", "enqueue"] def test_save_workflow_composer_rejects_agent_app_variant(sqlite_session: Session): session = sqlite_session payload = ComposerSavePayload.model_validate( { "variant": ComposerVariant.AGENT_APP.value, "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, "agent_soul": {"prompt": {"system_prompt": "x"}}, } ) with pytest.raises(ValueError): AgentComposerService.save_workflow_composer( session=session, tenant_id="tenant-1", app_id="app-1", node_id="node-1", account_id="account-1", payload=payload, ) def _duplicate_env_secret_payload(strategy: ComposerSaveStrategy) -> ComposerSavePayload: return ComposerSavePayload.model_validate( { "variant": ComposerVariant.AGENT_APP.value, "save_strategy": strategy.value, "agent_soul": { "prompt": {"system_prompt": "x"}, "env": { "variables": [{"name": "TOKEN", "value": "plain"}], "secret_refs": [{"name": "TOKEN", "value": "credential-1"}], }, }, } ) @pytest.mark.parametrize( "strategy", [ ComposerSaveStrategy.NODE_JOB_ONLY, ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION, ], ) def test_draft_save_strategies_skip_publish_validation(strategy: ComposerSaveStrategy): composer_service._validate_composer_payload_for_strategy(_duplicate_env_secret_payload(strategy)) @pytest.mark.parametrize( "strategy", [ ComposerSaveStrategy.SAVE_AS_NEW_VERSION, ComposerSaveStrategy.SAVE_AS_NEW_AGENT, ComposerSaveStrategy.SAVE_TO_ROSTER, ], ) def test_publish_save_strategies_run_publish_validation(strategy: ComposerSaveStrategy): with pytest.raises(InvalidComposerConfigError, match="duplicate env/secret name 'TOKEN'"): composer_service._validate_composer_payload_for_strategy(_duplicate_env_secret_payload(strategy)) def test_save_agent_app_composer_creates_agent_when_missing(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): session = sqlite_session saved_draft = SimpleNamespace( id="draft-1", home_snapshot_id="home-initial", config_snapshot_dict={"prompt": {"system_prompt": "x"}}, ) monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None) monkeypatch.setattr(AgentComposerService, "_save_agent_draft", lambda **kwargs: saved_draft) monkeypatch.setattr(AgentComposerService, "load_agent_composer", lambda **kwargs: {"loaded": True}) payload = ComposerSavePayload.model_validate( { "variant": ComposerVariant.AGENT_APP.value, "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, "new_agent_name": "Analyst", "agent_soul": {"prompt": {"system_prompt": "x"}}, } ) result = AgentComposerService.save_agent_app_composer( session=session, tenant_id="tenant-1", app_id="app-1", account_id="account-1", payload=payload, ) assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []} assert result == {"loaded": True} agent = session.scalar(select(Agent).where(Agent.app_id == "app-1")) assert agent is not None snapshot = session.scalar(select(AgentConfigSnapshot).where(AgentConfigSnapshot.agent_id == agent.id)) assert snapshot is not None assert agent.name == "Analyst" assert agent.active_config_snapshot_id == snapshot.id assert snapshot.home_snapshot_id is None assert agent.active_config_is_published is False def test_load_agent_app_composer_exposes_draft_save_only(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): session = sqlite_session agent = SimpleNamespace( id="agent-1", active_config_snapshot_id="version-1", active_config_is_published=True, updated_by="account-1", created_by="account-1", app_id="app-1", backing_app_id="app-1", scope=AgentScope.ROSTER, status=AgentStatus.ACTIVE, ) draft = SimpleNamespace(config_snapshot_dict={"prompt": {"system_prompt": "x"}}) monkeypatch.setattr(AgentComposerService, "_require_agent_app_agent", lambda **kwargs: agent) monkeypatch.setattr(AgentComposerService, "_get_or_create_agent_draft", lambda **kwargs: draft) monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: None) monkeypatch.setattr(AgentComposerService, "_serialize_agent", lambda _agent: {"id": _agent.id}) monkeypatch.setattr(AgentComposerService, "_serialize_version", lambda _version: None) monkeypatch.setattr(AgentComposerService, "_serialize_draft", lambda _draft: {"id": "draft-1"}) result = AgentComposerService.load_agent_app_composer(session=session, tenant_id="tenant-1", app_id="app-1") assert result["save_options"] == [ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value] assert result["active_config_is_published"] is True def test_save_agent_app_composer_rejects_version_save_strategy(sqlite_session: Session): session = sqlite_session payload = ComposerSavePayload.model_validate( { "variant": ComposerVariant.AGENT_APP.value, "save_strategy": ComposerSaveStrategy.SAVE_AS_NEW_VERSION.value, "agent_soul": {"prompt": {"system_prompt": "x"}}, } ) with pytest.raises(InvalidComposerConfigError, match="Use the publish endpoint"): AgentComposerService.save_agent_app_composer( session=session, tenant_id="tenant-1", app_id="app-1", account_id="account-1", payload=payload, ) def test_save_agent_app_composer_updates_normal_draft(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): session = sqlite_session agent = _agent(source=AgentSource.AGENT_APP, app_id="app-1") agent.active_config_snapshot_id = "version-1" agent.active_config_is_published = True agent.updated_by = None session.add(agent) session.commit() active_version = SimpleNamespace( home_snapshot_id="home-initial", config_snapshot_dict=AgentSoulConfig().model_dump(mode="json") ) saved = {} monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None) monkeypatch.setattr( AgentComposerService, "_save_agent_draft", lambda **kwargs: saved.update(kwargs) or SimpleNamespace(id="draft-1", home_snapshot_id="home-initial"), ) monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **_kwargs: active_version) monkeypatch.setattr( AgentComposerService, "load_agent_composer", lambda **kwargs: {"loaded": True, "active_config_is_published": agent.active_config_is_published}, ) payload = ComposerSavePayload.model_validate( { "variant": ComposerVariant.AGENT_APP.value, "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, "agent_soul": _agent_soul_with_model().model_dump(mode="json"), } ) result = AgentComposerService.save_agent_app_composer( session=session, tenant_id="tenant-1", app_id="app-1", account_id="account-1", payload=payload, ) assert result.pop("validation") == {"warnings": [], "knowledge_retrieval_placeholder": []} assert result == {"loaded": True, "active_config_is_published": False} assert saved["draft_type"] == AgentConfigDraftType.DRAFT assert saved["agent_soul"].model_dump(mode="json") == _agent_soul_with_model().model_dump(mode="json") assert agent.active_config_is_published is False def test_save_agent_app_composer_keeps_published_when_draft_matches_active_snapshot( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): agent_soul = _agent_soul_with_model() session = sqlite_session agent = _agent(source=AgentSource.AGENT_APP, app_id="app-1") agent.active_config_snapshot_id = "version-1" agent.active_config_is_published = False agent.updated_by = None revision = AgentConfigRevision( tenant_id="tenant-1", agent_id=agent.id, current_snapshot_id="version-1", revision=1, operation=AgentConfigRevisionOperation.PUBLISH_DRAFT, ) session.add_all([agent, revision]) session.commit() active_version = SimpleNamespace( home_snapshot_id="home-initial", config_snapshot_dict=agent_soul.model_dump(mode="json") ) monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_draft_save_payload", lambda payload: None) monkeypatch.setattr( AgentComposerService, "_save_agent_draft", lambda **_kwargs: SimpleNamespace(id="draft-1", home_snapshot_id="home-initial"), ) monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **_kwargs: active_version) monkeypatch.setattr( AgentComposerService, "load_agent_composer", lambda **_kwargs: {"loaded": True, "active_config_is_published": agent.active_config_is_published}, ) payload = ComposerSavePayload.model_validate( { "variant": ComposerVariant.AGENT_APP.value, "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, "agent_soul": agent_soul.model_dump(mode="json"), } ) result = AgentComposerService.save_agent_app_composer( session=session, tenant_id="tenant-1", app_id="app-1", account_id="account-1", payload=payload, ) assert agent.active_config_is_published is True assert result["active_config_is_published"] is True def test_publish_agent_app_draft_rejects_missing_model(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): session = sqlite_session 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(), ) session.add_all([agent, draft]) session.commit() 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.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", session=session, ) 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 not session.new assert not session.dirty def test_publish_agent_app_draft_creates_published_snapshot(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): session = sqlite_session 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", ) draft = AgentConfigDraft( tenant_id="tenant-1", agent_id="agent-1", draft_type=AgentConfigDraftType.DRAFT, draft_owner_key="", base_snapshot_id="version-1", home_snapshot_id=None, config_snapshot=_agent_soul_with_model(), ) version = SimpleNamespace(id="version-2") session.add_all([agent, draft]) session.commit() created: dict[str, object] = {} calls: list[str] = [] monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", lambda payload: None) monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", lambda **kwargs: None) monkeypatch.setattr( composer_service, "validate_home_snapshot_binding", lambda **kwargs: calls.append("validate_home"), ) monkeypatch.setattr( AgentComposerService, "_create_config_version", lambda **kwargs: calls.append("create_version") or created.update(kwargs) or version, ) monkeypatch.setattr(AgentComposerService, "_serialize_version", lambda _version: {"id": _version.id}) result = AgentComposerService.publish_agent_app_draft( session=session, tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", version_note="ship it", ) assert result["result"] == "success" assert result["active_config_snapshot_id"] == "version-2" assert result["draft"]["base_snapshot_id"] == "version-2" assert created["operation"] == AgentConfigRevisionOperation.PUBLISH_DRAFT assert created["previous_snapshot_id"] == "version-1" assert created["home_snapshot_id"] is None assert calls == ["validate_home", "create_version"] assert agent.active_config_snapshot_id == "version-2" assert agent.active_config_has_model is True assert agent.active_config_is_published is True def test_repeated_publish_reuses_normal_draft_home_without_creating_resources( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, ) -> None: session = sqlite_session 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", ) draft = AgentConfigDraft( id="draft-1", tenant_id="tenant-1", agent_id="agent-1", draft_type=AgentConfigDraftType.DRAFT, draft_owner_key="", home_snapshot_id="home-1", config_snapshot=_agent_soul_with_model(), ) session.add_all([agent, draft]) session.commit() published_homes: list[str] = [] versions = iter([SimpleNamespace(id="version-2"), SimpleNamespace(id="version-3")]) create_from_build = MagicMock() monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", lambda _payload: None) monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", lambda **_kwargs: None) monkeypatch.setattr(composer_service, "validate_home_snapshot_binding", lambda **_kwargs: None) monkeypatch.setattr( AgentComposerService, "_create_config_version", lambda **kwargs: published_homes.append(kwargs["home_snapshot_id"]) or next(versions), ) monkeypatch.setattr(AgentComposerService, "_serialize_version", lambda version: {"id": version.id}) monkeypatch.setattr(AgentComposerService, "_serialize_draft", lambda value: {"id": value.id}) monkeypatch.setattr(AgentHomeSnapshotService, "create_for_build_apply", create_from_build) first = AgentComposerService.publish_agent_app_draft( session=session, tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", ) second = AgentComposerService.publish_agent_app_draft( session=session, tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", ) assert first["active_config_snapshot_id"] == "version-2" assert second["active_config_snapshot_id"] == "version-3" assert published_homes == ["home-1", "home-1"] assert draft.home_snapshot_id == "home-1" create_from_build.assert_not_called() def test_agent_app_build_draft_checkout_and_apply_use_user_isolated_draft( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): session = sqlite_session 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=True, ) normal_draft = AgentConfigDraft( tenant_id="tenant-1", agent_id="agent-1", draft_type=AgentConfigDraftType.DRAFT, account_id=None, draft_owner_key="", base_snapshot_id="version-1", home_snapshot_id="home-initial", config_snapshot=_agent_soul_with_model(), ) active_version = AgentConfigSnapshot( id="version-1", tenant_id="tenant-1", agent_id=agent.id, version=1, home_snapshot_id=normal_draft.home_snapshot_id, config_snapshot=normal_draft.config_snapshot, ) publish_revision = AgentConfigRevision( tenant_id="tenant-1", agent_id=agent.id, current_snapshot_id=active_version.id, revision=1, operation=AgentConfigRevisionOperation.PUBLISH_DRAFT, ) session.add_all([agent, normal_draft, active_version, publish_revision]) session.commit() checked_out = AgentComposerService.checkout_agent_app_build_draft( session=session, tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", ) build_draft = session.scalar( select(AgentConfigDraft).where( AgentConfigDraft.agent_id == agent.id, AgentConfigDraft.draft_type == AgentConfigDraftType.DEBUG_BUILD, AgentConfigDraft.account_id == "account-1", ) ) assert build_draft is not None assert checked_out["draft"]["id"] == build_draft.id assert checked_out["draft"]["draft_type"] == AgentConfigDraftType.DEBUG_BUILD.value assert checked_out["draft"]["account_id"] == "account-1" assert checked_out["draft"]["base_snapshot_id"] == "version-1" assert build_draft.home_snapshot_id == "home-initial" assert checked_out["agent_soul"] == normal_draft.config_snapshot_dict source_binding_id = "binding-1" build_draft.agent_workspace_binding_id = source_binding_id session.commit() create_home = MagicMock(return_value=SimpleNamespace(id="home-build", snapshot_ref="backend-home-build")) monkeypatch.setattr(AgentHomeSnapshotService, "create_for_build_apply", create_home) retire_binding = MagicMock() enqueue_collection = MagicMock() monkeypatch.setattr(AgentWorkspaceService, "retire_binding", retire_binding) monkeypatch.setattr(composer_service, "enqueue_agent_resource_collection", enqueue_collection) applied = AgentComposerService.apply_agent_app_build_draft( session=session, tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", ) assert applied["result"] == "success" assert applied["draft"]["id"] == normal_draft.id assert normal_draft.config_snapshot_dict == build_draft.config_snapshot_dict assert normal_draft.home_snapshot_id == "home-build" assert agent.active_config_is_published is False assert session.get(AgentConfigDraft, build_draft.id) is None create_home.assert_called_once_with( session=session, build_draft=build_draft, ) retire_binding.assert_called_once_with(session=session, tenant_id="tenant-1", binding_id=source_binding_id) enqueue_collection.assert_called_once_with(tenant_id="tenant-1", binding_ids=[source_binding_id]) @pytest.mark.parametrize( ("scope", "source", "app_id", "backing_app_id", "expected_runtime_app_id"), [ (AgentScope.ROSTER, AgentSource.AGENT_APP, "app-1", None, "app-1"), ( AgentScope.WORKFLOW_ONLY, AgentSource.WORKFLOW, "workflow-app-1", "runtime-app-1", "runtime-app-1", ), ], ) def test_force_build_draft_checkout_collects_retired_binding_after_commit( monkeypatch: pytest.MonkeyPatch, scope: AgentScope, source: AgentSource, app_id: str, backing_app_id: str | None, expected_runtime_app_id: str, sqlite_session: Session, ) -> None: session = sqlite_session agent = Agent( id="agent-1", tenant_id="tenant-1", name="Iris", description="", agent_kind=AgentKind.DIFY_AGENT, scope=scope, source=source, status=AgentStatus.ACTIVE, app_id=app_id, backing_app_id=backing_app_id, ) normal_draft = AgentConfigDraft( tenant_id="tenant-1", agent_id=agent.id, draft_type=AgentConfigDraftType.DRAFT, draft_owner_key="", home_snapshot_id="home-1", config_snapshot=_agent_soul_with_model(), ) build_draft = AgentConfigDraft( id="build-1", tenant_id="tenant-1", agent_id=agent.id, draft_type=AgentConfigDraftType.DEBUG_BUILD, account_id="account-1", draft_owner_key="account-1", home_snapshot_id="home-1", agent_workspace_binding_id="binding-1", config_snapshot=_agent_soul_with_model(), ) binding = SimpleNamespace( agent_id=agent.id, base_home_snapshot_id="home-1", agent_config_version_id=build_draft.id, agent_config_version_kind=AgentConfigVersionKind.BUILD_DRAFT, ) get_active_binding = MagicMock(return_value=binding) retire_binding = MagicMock(return_value="binding-1") enqueue_collection = MagicMock() monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", get_active_binding) monkeypatch.setattr(AgentWorkspaceService, "retire_binding", retire_binding) monkeypatch.setattr(composer_service, "enqueue_agent_resource_collection", enqueue_collection) session.add_all([agent, normal_draft, build_draft]) session.commit() AgentComposerService.checkout_agent_app_build_draft( session=session, tenant_id="tenant-1", agent_id=agent.id, account_id="account-1", ) assert build_draft.agent_workspace_binding_id == "binding-1" retire_binding.assert_not_called() enqueue_collection.assert_not_called() AgentComposerService.checkout_agent_app_build_draft( session=session, tenant_id="tenant-1", agent_id=agent.id, account_id="account-1", force=True, ) assert build_draft.agent_workspace_binding_id is None owner_scope = get_active_binding.call_args.kwargs["expected_owner_scope"] assert owner_scope.app_id == expected_runtime_app_id assert owner_scope.owner_type is AgentWorkspaceOwnerType.BUILD_DRAFT assert owner_scope.owner_id == build_draft.id retire_binding.assert_called_once_with( session=session, tenant_id="tenant-1", binding_id="binding-1", ) enqueue_collection.assert_called_once_with( tenant_id="tenant-1", binding_ids=("binding-1",), ) def test_force_build_draft_checkout_rejects_unavailable_pointed_binding( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, ) -> None: session = sqlite_session 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, app_id="app-1", ) normal_draft = AgentConfigDraft( tenant_id="tenant-1", agent_id=agent.id, draft_type=AgentConfigDraftType.DRAFT, draft_owner_key="", home_snapshot_id="home-1", config_snapshot=_agent_soul_with_model(), ) build_draft = AgentConfigDraft( id="build-1", tenant_id="tenant-1", agent_id=agent.id, draft_type=AgentConfigDraftType.DEBUG_BUILD, account_id="account-1", draft_owner_key="account-1", home_snapshot_id="home-1", agent_workspace_binding_id="binding-missing", config_snapshot=_agent_soul_with_model(), ) session.add_all([agent, normal_draft, build_draft]) session.commit() retire_binding = MagicMock() enqueue_collection = MagicMock() monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", MagicMock(return_value=None)) monkeypatch.setattr(AgentWorkspaceService, "retire_binding", retire_binding) monkeypatch.setattr(composer_service, "enqueue_agent_resource_collection", enqueue_collection) with pytest.raises(AgentBuildSandboxNotFoundError): AgentComposerService.checkout_agent_app_build_draft( session=session, tenant_id="tenant-1", agent_id=agent.id, account_id="account-1", force=True, ) assert build_draft.agent_workspace_binding_id == "binding-missing" assert session.get(AgentConfigDraft, build_draft.id).agent_workspace_binding_id == "binding-missing" retire_binding.assert_not_called() enqueue_collection.assert_not_called() def test_build_apply_checkpoints_binding_updates_normal_draft_then_collects( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: session = sqlite_session agent = Agent( id="agent-1", tenant_id="tenant-1", name="Iris", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, app_id="app-1", ) build_draft = AgentConfigDraft( id="build-1", tenant_id="tenant-1", agent_id="agent-1", draft_type=AgentConfigDraftType.DEBUG_BUILD, account_id="account-1", draft_owner_key="account-1", home_snapshot_id=None, agent_workspace_binding_id="binding-1", config_snapshot=AgentSoulConfig(), ) normal_draft = AgentConfigDraft( id="draft-1", tenant_id="tenant-1", agent_id="agent-1", draft_type=AgentConfigDraftType.DRAFT, draft_owner_key="", home_snapshot_id=None, config_snapshot=AgentSoulConfig(), ) source_binding = SimpleNamespace( id="binding-1", backend_binding_ref="backend-binding-1", agent_id="agent-1", base_home_snapshot_id=None, agent_config_version_id="build-1", agent_config_version_kind=AgentConfigVersionKind.BUILD_DRAFT, ) session.add_all([agent, build_draft]) session.commit() client = MagicMock() client.create_home_snapshot_from_binding_sync.return_value = SimpleNamespace(snapshot_ref="snapshot-ref-new") lifecycle: list[str] = [] event.listen(session, "after_commit", lambda _session: lifecycle.append("commit")) retire = MagicMock(return_value=source_binding.id) enqueue_collection = MagicMock(side_effect=lambda **_kwargs: lifecycle.append("enqueue")) monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", lambda _payload: None) monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", lambda **_kwargs: None) monkeypatch.setattr(AgentHomeSnapshotService, "_client", lambda: nullcontext(client)) monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", MagicMock(return_value=source_binding)) monkeypatch.setattr(AgentComposerService, "_save_agent_draft", lambda **_kwargs: normal_draft) monkeypatch.setattr(AgentComposerService, "_agent_soul_matches_active_config", lambda **_kwargs: False) monkeypatch.setattr(AgentComposerService, "_serialize_draft", lambda draft: {"id": draft.id}) monkeypatch.setattr(AgentWorkspaceService, "retire_binding", retire) monkeypatch.setattr(composer_service, "enqueue_agent_resource_collection", enqueue_collection) result = AgentComposerService.apply_agent_app_build_draft( session=session, tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", ) request = client.create_home_snapshot_from_binding_sync.call_args.args[0] assert request.backend_binding_ref == "backend-binding-1" created_home = session.scalar(select(AgentHomeSnapshot).where(AgentHomeSnapshot.snapshot_ref == "snapshot-ref-new")) assert created_home is not None assert created_home.snapshot_ref == "snapshot-ref-new" assert normal_draft.home_snapshot_id == created_home.id retire.assert_called_once_with(session=session, tenant_id="tenant-1", binding_id="binding-1") enqueue_collection.assert_called_once_with(tenant_id="tenant-1", binding_ids=["binding-1"]) assert lifecycle == ["commit", "enqueue"] assert result == {"result": "success", "draft": {"id": "draft-1"}} def test_build_apply_retires_normal_preview_binding_before_replacing_draft_home( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, ) -> None: session = sqlite_session agent = Agent( id="agent-1", tenant_id="tenant-1", name="Iris", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, app_id="app-1", ) build_draft = AgentConfigDraft( id="build-1", tenant_id="tenant-1", agent_id=agent.id, draft_type=AgentConfigDraftType.DEBUG_BUILD, account_id="account-1", draft_owner_key="account-1", home_snapshot_id="home-build", agent_workspace_binding_id="binding-build", config_snapshot=AgentSoulConfig(), ) normal_draft = AgentConfigDraft( id="draft-1", tenant_id="tenant-1", agent_id=agent.id, draft_type=AgentConfigDraftType.DRAFT, draft_owner_key="", home_snapshot_id="home-preview-old", config_snapshot=AgentSoulConfig(), ) preview_mapping = AgentDebugConversation( tenant_id=agent.tenant_id, agent_id=agent.id, app_id="app-1", account_id="account-2", draft_type=AgentConfigDraftType.DRAFT, conversation_id="conversation-preview", ) empty_preview_mapping = AgentDebugConversation( tenant_id=agent.tenant_id, agent_id=agent.id, app_id="app-1", account_id="account-3", draft_type=AgentConfigDraftType.DRAFT, conversation_id="conversation-preview-empty", ) preview_conversation = _conversation(conversation_id="conversation-preview", account_id="account-2") preview_conversation.agent_workspace_binding_id = "binding-preview" empty_preview_conversation = _conversation(conversation_id="conversation-preview-empty", account_id="account-3") preview_binding = SimpleNamespace( id="binding-preview", agent_id=agent.id, base_home_snapshot_id="home-preview-old", agent_config_version_id=normal_draft.id, agent_config_version_kind=AgentConfigVersionKind.DRAFT, ) session.add_all( [ agent, build_draft, preview_mapping, empty_preview_mapping, preview_conversation, empty_preview_conversation, ] ) session.commit() lifecycle: list[str] = [] event.listen(session, "after_commit", lambda _session: lifecycle.append("commit")) monkeypatch.setattr( AgentHomeSnapshotService, "create_for_build_apply", MagicMock(return_value=SimpleNamespace(id="home-applied")), ) monkeypatch.setattr(AgentComposerService, "_save_agent_draft", MagicMock(return_value=normal_draft)) monkeypatch.setattr(AgentComposerService, "_agent_soul_matches_active_config", MagicMock(return_value=False)) monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", MagicMock()) monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", MagicMock()) get_active_binding = MagicMock(return_value=preview_binding) retire_binding = MagicMock(side_effect=["binding-preview", "binding-build"]) validate_generation = MagicMock() enqueue_collection = MagicMock(side_effect=lambda **_kwargs: lifecycle.append("enqueue")) monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", get_active_binding) monkeypatch.setattr(AgentWorkspaceService, "validate_binding_generation", validate_generation) monkeypatch.setattr(AgentWorkspaceService, "retire_binding", retire_binding) monkeypatch.setattr(composer_service, "enqueue_agent_resource_collection", enqueue_collection) AgentComposerService.apply_agent_app_build_draft( session=session, tenant_id=agent.tenant_id, agent_id=agent.id, account_id="account-1", ) assert preview_conversation.agent_workspace_binding_id is None validate_generation.assert_called_once_with( preview_binding, base_home_snapshot_id="home-preview-old", agent_config_version_id=normal_draft.id, agent_config_version_kind=AgentConfigVersionKind.DRAFT, ) enqueue_collection.assert_called_once_with( tenant_id=agent.tenant_id, binding_ids=["binding-preview", "binding-build"], ) assert get_active_binding.call_count == 1 assert get_active_binding.call_args.kwargs["binding_id"] == "binding-preview" assert retire_binding.call_args_list == [ call(session=session, tenant_id=agent.tenant_id, binding_id="binding-preview"), call(session=session, tenant_id=agent.tenant_id, binding_id="binding-build"), ] assert lifecycle == ["commit", "enqueue"] def test_build_apply_validates_before_resolving_or_snapshotting_sandbox( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: session = sqlite_session 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, ) build_draft = AgentConfigDraft( id="build-1", tenant_id="tenant-1", agent_id="agent-1", draft_type=AgentConfigDraftType.DEBUG_BUILD, account_id="account-1", draft_owner_key="account-1", home_snapshot_id="home-1", config_snapshot=_agent_soul_with_model(), ) session.add_all([agent, build_draft]) session.commit() validation = MagicMock(side_effect=InvalidComposerConfigError("invalid Build Draft")) create_home = MagicMock() monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", validation) monkeypatch.setattr(AgentHomeSnapshotService, "create_for_build_apply", create_home) with pytest.raises(InvalidComposerConfigError, match="invalid Build Draft"): AgentComposerService.apply_agent_app_build_draft( session=session, tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", ) validation.assert_called_once() create_home.assert_not_called() def test_build_apply_without_model_snapshots_source_sandbox_and_updates_normal_draft( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, ) -> None: session = sqlite_session 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, ) build_draft = AgentConfigDraft( id="build-1", tenant_id="tenant-1", agent_id="agent-1", draft_type=AgentConfigDraftType.DEBUG_BUILD, account_id="account-1", draft_owner_key="account-1", home_snapshot_id="home-old", agent_workspace_binding_id="binding-1", config_snapshot=AgentSoulConfig(), ) normal_draft = AgentConfigDraft( id="draft-1", tenant_id="tenant-1", agent_id="agent-1", draft_type=AgentConfigDraftType.DRAFT, draft_owner_key="", home_snapshot_id="home-old", config_snapshot=AgentSoulConfig(), ) session.add_all([agent, build_draft]) session.commit() create_home = MagicMock(return_value=SimpleNamespace(id="home-new", snapshot_ref="backend-home-new")) validate_knowledge = MagicMock() monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", lambda _payload: None) monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", validate_knowledge) monkeypatch.setattr(AgentHomeSnapshotService, "create_for_build_apply", create_home) monkeypatch.setattr(AgentComposerService, "_save_agent_draft", lambda **_kwargs: normal_draft) monkeypatch.setattr(AgentComposerService, "_agent_soul_matches_active_config", lambda **_kwargs: False) monkeypatch.setattr(AgentWorkspaceService, "retire_binding", MagicMock()) monkeypatch.setattr(composer_service, "enqueue_agent_resource_collection", MagicMock()) monkeypatch.setattr(AgentComposerService, "_serialize_draft", lambda draft: {"id": draft.id}) result = AgentComposerService.apply_agent_app_build_draft( session=session, tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", ) create_home.assert_called_once_with( session=session, build_draft=build_draft, ) validate_knowledge.assert_called_once() assert normal_draft.home_snapshot_id == "home-new" assert session.get(AgentConfigDraft, build_draft.id) is None assert result == {"result": "success", "draft": {"id": "draft-1"}} def test_build_apply_requires_retained_sandbox_before_creating_home( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: session = sqlite_session 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, ) build_draft = AgentConfigDraft( id="build-1", tenant_id="tenant-1", agent_id="agent-1", draft_type=AgentConfigDraftType.DEBUG_BUILD, account_id="account-1", draft_owner_key="account-1", home_snapshot_id="home-old", config_snapshot=AgentSoulConfig(), ) create_home = MagicMock() save_draft = MagicMock() monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", lambda _payload: None) monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", lambda **_kwargs: None) monkeypatch.setattr(AgentHomeSnapshotService, "create_for_build_apply", create_home) monkeypatch.setattr(AgentComposerService, "_save_agent_draft", save_draft) session.add_all([agent, build_draft]) session.commit() with pytest.raises(AgentBuildSandboxNotFoundError): AgentComposerService.apply_agent_app_build_draft( session=session, tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", ) create_home.assert_not_called() save_draft.assert_not_called() assert build_draft.home_snapshot_id == "home-old" assert session.get(AgentConfigDraft, build_draft.id) is not None def test_build_apply_fails_when_locked_source_cannot_be_retired( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: session = sqlite_session 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, ) build_draft = AgentConfigDraft( id="build-1", tenant_id="tenant-1", agent_id=agent.id, draft_type=AgentConfigDraftType.DEBUG_BUILD, account_id="account-1", draft_owner_key="account-1", home_snapshot_id="home-old", agent_workspace_binding_id="binding-1", config_snapshot=AgentSoulConfig(), ) normal_draft = AgentConfigDraft( id="draft-1", tenant_id="tenant-1", agent_id=agent.id, draft_type=AgentConfigDraftType.DRAFT, draft_owner_key="", home_snapshot_id="home-old", config_snapshot=AgentSoulConfig(), ) session.add_all([agent, build_draft]) session.commit() monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", lambda _payload: None) monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", lambda **_kwargs: None) monkeypatch.setattr( AgentHomeSnapshotService, "create_for_build_apply", MagicMock(return_value=SimpleNamespace(id="home-new", snapshot_ref="backend-home-new")), ) monkeypatch.setattr(AgentComposerService, "_save_agent_draft", MagicMock(return_value=normal_draft)) monkeypatch.setattr(AgentComposerService, "_agent_soul_matches_active_config", lambda **_kwargs: False) monkeypatch.setattr(AgentWorkspaceService, "retire_binding", MagicMock(return_value=None)) enqueue_collection = MagicMock() monkeypatch.setattr(composer_service, "enqueue_agent_resource_collection", enqueue_collection) with pytest.raises(AgentBuildSandboxNotFoundError): AgentComposerService.apply_agent_app_build_draft( session=session, tenant_id="tenant-1", agent_id=agent.id, account_id="account-1", ) assert session.get(AgentConfigDraft, build_draft.id) is not None enqueue_collection.assert_not_called() def test_build_apply_home_create_failure_leaves_drafts_untouched( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: session = sqlite_session 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, ) build_draft = AgentConfigDraft( id="build-1", tenant_id="tenant-1", agent_id="agent-1", draft_type=AgentConfigDraftType.DEBUG_BUILD, account_id="account-1", draft_owner_key="account-1", home_snapshot_id="home-old", agent_workspace_binding_id="binding-1", config_snapshot=AgentSoulConfig(), ) normal_draft = AgentConfigDraft( id="draft-1", tenant_id="tenant-1", agent_id="agent-1", draft_type=AgentConfigDraftType.DRAFT, draft_owner_key="", home_snapshot_id="home-old", config_snapshot=AgentSoulConfig(), ) save_draft = MagicMock(return_value=normal_draft) monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", lambda _payload: None) monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", lambda **_kwargs: None) monkeypatch.setattr( AgentHomeSnapshotService, "create_for_build_apply", MagicMock(side_effect=RuntimeError("snapshot failed")), ) monkeypatch.setattr(AgentComposerService, "_save_agent_draft", save_draft) session.add_all([agent, build_draft]) session.commit() with pytest.raises(RuntimeError, match="snapshot failed"): AgentComposerService.apply_agent_app_build_draft( session=session, tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", ) save_draft.assert_not_called() assert build_draft.home_snapshot_id == "home-old" assert normal_draft.home_snapshot_id == "home-old" assert session.get(AgentConfigDraft, build_draft.id) is not None def test_build_apply_commit_failure_rolls_back_and_preserves_physical_home( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, ) -> None: session = sqlite_session 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, ) build_draft = AgentConfigDraft( id="build-1", tenant_id="tenant-1", agent_id="agent-1", draft_type=AgentConfigDraftType.DEBUG_BUILD, account_id="account-1", draft_owner_key="account-1", home_snapshot_id="home-old", agent_workspace_binding_id="binding-1", config_snapshot=AgentSoulConfig(), ) normal_draft = AgentConfigDraft( id="draft-1", tenant_id="tenant-1", agent_id="agent-1", draft_type=AgentConfigDraftType.DRAFT, draft_owner_key="", home_snapshot_id="home-old", config_snapshot=AgentSoulConfig(), ) session.add_all([agent, build_draft]) session.commit() def fail_commit(_session: Session) -> None: raise RuntimeError("commit failed") event.listen(session, "before_commit", fail_commit) delete_home = MagicMock() monkeypatch.setattr(composer_service.ComposerConfigValidator, "validate_publish_payload", lambda _payload: None) monkeypatch.setattr(AgentComposerService, "validate_knowledge_datasets", lambda **_kwargs: None) monkeypatch.setattr( AgentHomeSnapshotService, "create_for_build_apply", lambda **_kwargs: SimpleNamespace(id="home-new", snapshot_ref="backend-home-new"), ) monkeypatch.setattr(AgentHomeSnapshotService, "delete", delete_home) monkeypatch.setattr(AgentComposerService, "_save_agent_draft", lambda **_kwargs: normal_draft) monkeypatch.setattr(AgentComposerService, "_agent_soul_matches_active_config", lambda **_kwargs: False) monkeypatch.setattr(AgentWorkspaceService, "retire_binding", MagicMock(return_value="binding-1")) enqueue_collection = MagicMock() monkeypatch.setattr(composer_service, "enqueue_agent_resource_collection", enqueue_collection) with pytest.raises(RuntimeError, match="commit failed"): AgentComposerService.apply_agent_app_build_draft( session=session, tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", ) assert session.get(AgentConfigDraft, build_draft.id) is not None delete_home.assert_not_called() enqueue_collection.assert_not_called() def test_build_draft_save_and_discard_do_not_manage_home_resources( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: 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, ) build_draft = AgentConfigDraft( id="build-1", tenant_id="tenant-1", agent_id="agent-1", draft_type=AgentConfigDraftType.DEBUG_BUILD, account_id="account-1", draft_owner_key="account-1", home_snapshot_id="home-existing", config_snapshot=AgentSoulConfig(), ) create_from_build = MagicMock() delete_home = MagicMock() monkeypatch.setattr(AgentHomeSnapshotService, "create_for_build_apply", create_from_build) monkeypatch.setattr(AgentHomeSnapshotService, "delete", delete_home) monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **_kwargs: agent) monkeypatch.setattr(AgentComposerService, "_save_agent_draft", lambda **_kwargs: build_draft) monkeypatch.setattr(AgentComposerService, "_serialize_build_draft_state", lambda draft: {"id": draft.id}) save_session = sqlite_session AgentComposerService.save_agent_app_build_draft( session=save_session, tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", payload=ComposerSavePayload( variant=ComposerVariant.AGENT_APP, save_strategy=ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION, agent_soul=AgentSoulConfig(), ), ) discard_session = sqlite_session discard_session.add(build_draft) discard_session.commit() AgentComposerService.discard_agent_app_build_draft( session=discard_session, tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", ) create_from_build.assert_not_called() delete_home.assert_not_called() def test_build_discard_retires_then_commits_before_enqueue( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: session = sqlite_session agent = _agent(source=AgentSource.AGENT_APP, app_id="app-1") build_draft = AgentConfigDraft( id="build-1", tenant_id=agent.tenant_id, agent_id=agent.id, draft_type=AgentConfigDraftType.DEBUG_BUILD, account_id="account-1", draft_owner_key="account-1", home_snapshot_id="home-1", agent_workspace_binding_id="binding-1", config_snapshot=AgentSoulConfig(), ) binding = SimpleNamespace( agent_id=agent.id, base_home_snapshot_id="home-1", agent_config_version_id=build_draft.id, agent_config_version_kind=AgentConfigVersionKind.BUILD_DRAFT, ) session.add_all([agent, build_draft]) session.commit() events: list[str] = [] monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", MagicMock(return_value=binding)) monkeypatch.setattr( AgentWorkspaceService, "retire_binding", MagicMock(side_effect=lambda **_kwargs: events.append("retire") or "binding-1"), ) event.listen(session, "after_commit", lambda _session: events.append("commit")) monkeypatch.setattr( composer_service, "enqueue_agent_resource_collection", MagicMock(side_effect=lambda **_kwargs: events.append("enqueue")), ) AgentComposerService.discard_agent_app_build_draft( session=session, tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", ) assert events == ["retire", "commit", "enqueue"] def test_build_discard_commit_failure_does_not_enqueue( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: session = sqlite_session agent = _agent(source=AgentSource.AGENT_APP, app_id="app-1") build_draft = AgentConfigDraft( id="build-1", tenant_id=agent.tenant_id, agent_id=agent.id, draft_type=AgentConfigDraftType.DEBUG_BUILD, account_id="account-1", draft_owner_key="account-1", home_snapshot_id="home-1", agent_workspace_binding_id="binding-1", config_snapshot=AgentSoulConfig(), ) binding = SimpleNamespace( agent_id=agent.id, base_home_snapshot_id="home-1", agent_config_version_id=build_draft.id, agent_config_version_kind=AgentConfigVersionKind.BUILD_DRAFT, ) session.add_all([agent, build_draft]) session.commit() monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", MagicMock(return_value=binding)) monkeypatch.setattr(AgentWorkspaceService, "retire_binding", MagicMock(return_value="binding-1")) event.listen( session, "before_commit", lambda _session: (_ for _ in ()).throw(RuntimeError("commit failed")), ) enqueue_collection = MagicMock() monkeypatch.setattr(composer_service, "enqueue_agent_resource_collection", enqueue_collection) with pytest.raises(RuntimeError, match="commit failed"): AgentComposerService.discard_agent_app_build_draft( session=session, tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", ) enqueue_collection.assert_not_called() def test_build_discard_rejects_unavailable_pointed_binding( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: session = sqlite_session agent = _agent(source=AgentSource.AGENT_APP, app_id="app-1") build_draft = AgentConfigDraft( id="build-1", tenant_id=agent.tenant_id, agent_id=agent.id, draft_type=AgentConfigDraftType.DEBUG_BUILD, account_id="account-1", draft_owner_key="account-1", home_snapshot_id="home-1", agent_workspace_binding_id="binding-missing", config_snapshot=AgentSoulConfig(), ) session.add_all([agent, build_draft]) session.commit() retire_binding = MagicMock() enqueue_collection = MagicMock() monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", MagicMock(return_value=None)) monkeypatch.setattr(AgentWorkspaceService, "retire_binding", retire_binding) monkeypatch.setattr(composer_service, "enqueue_agent_resource_collection", enqueue_collection) with pytest.raises(AgentBuildSandboxNotFoundError): AgentComposerService.discard_agent_app_build_draft( session=session, tenant_id="tenant-1", agent_id=agent.id, account_id="account-1", ) assert build_draft.agent_workspace_binding_id == "binding-missing" assert session.get(AgentConfigDraft, build_draft.id) is not None retire_binding.assert_not_called() enqueue_collection.assert_not_called() @pytest.mark.parametrize( ("draft_type", "account_id"), [ (AgentConfigDraftType.DRAFT, None), (AgentConfigDraftType.DEBUG_BUILD, "account-1"), ], ) def test_load_agent_soul_for_debug_selects_requested_draft( draft_type: AgentConfigDraftType, account_id: str | None, sqlite_session: Session, ): session = sqlite_session 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, ) agent_soul = AgentSoulConfig.model_validate({"app_features": {"speech_to_text": {"enabled": True}}}) draft = AgentConfigDraft( tenant_id="tenant-1", agent_id="agent-1", draft_type=draft_type, account_id=account_id, draft_owner_key=account_id or "", config_snapshot=agent_soul, ) session.add_all([agent, draft]) session.commit() result = AgentComposerService.load_agent_soul_for_debug( tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", draft_type=draft_type, session=session, ) assert result == agent_soul def test_load_agent_soul_for_debug_requires_existing_build_draft(sqlite_session: Session): session = sqlite_session with pytest.raises(AgentVersionNotFoundError): AgentComposerService.load_agent_soul_for_debug( tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", draft_type=AgentConfigDraftType.DEBUG_BUILD, session=session, ) def test_agent_app_build_draft_apply_marks_unpublished_when_build_draft_differs( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): session = sqlite_session 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=True, ) active_agent_soul = _agent_soul_with_model() build_agent_soul = AgentSoulConfig.model_validate( { **active_agent_soul.model_dump(mode="json"), "prompt": { "system_prompt": "Build draft prompt", }, } ) build_draft = AgentConfigDraft( tenant_id="tenant-1", agent_id="agent-1", draft_type=AgentConfigDraftType.DEBUG_BUILD, account_id="account-1", draft_owner_key="account-1", base_snapshot_id="version-1", agent_workspace_binding_id="binding-1", config_snapshot=build_agent_soul, ) normal_draft = AgentConfigDraft( tenant_id="tenant-1", agent_id="agent-1", draft_type=AgentConfigDraftType.DRAFT, account_id=None, draft_owner_key="", base_snapshot_id="version-1", config_snapshot=active_agent_soul, ) active_version = AgentConfigSnapshot( id="version-1", tenant_id=agent.tenant_id, agent_id=agent.id, version=1, home_snapshot_id="home-initial", config_snapshot=active_agent_soul, ) session.add_all([agent, build_draft, normal_draft, active_version]) session.commit() create_home = MagicMock(return_value=SimpleNamespace(id="home-build", snapshot_ref="backend-home-build")) monkeypatch.setattr(AgentHomeSnapshotService, "create_for_build_apply", create_home) monkeypatch.setattr(AgentWorkspaceService, "retire_binding", MagicMock()) monkeypatch.setattr(composer_service, "enqueue_agent_resource_collection", MagicMock()) AgentComposerService.apply_agent_app_build_draft( session=session, tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", ) assert normal_draft.config_snapshot_dict == build_draft.config_snapshot_dict assert agent.active_config_is_published is False assert session.get(AgentConfigDraft, build_draft.id) is None create_home.assert_called_once_with( session=session, build_draft=build_draft, ) def test_agent_app_composer_candidates_and_impact(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): session = sqlite_session bindings = [ WorkflowAgentNodeBinding( tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version=Workflow.VERSION_DRAFT, node_id=node_id, binding_type=WorkflowAgentBindingType.ROSTER_AGENT, agent_id="agent-1", current_snapshot_id="version-1", node_job_config=WorkflowNodeJobConfig(), ) for node_id in ("node-1", "node-2") ] session.add_all(bindings) session.commit() # Candidates assembly is covered in test_composer_candidates.py; here we stub # the IO loaders and assert the response envelope per variant (ENG-615). def _no_draft_workflow(**kwargs): raise ValueError("draft workflow not found") monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", _no_draft_workflow) monkeypatch.setattr(AgentComposerService, "_load_agent_soul", lambda **kwargs: None) monkeypatch.setattr(AgentComposerService, "_workspace_dify_tools", lambda **kwargs: []) workflow_candidates = AgentComposerService.get_workflow_candidates( session=session, tenant_id="tenant-1", app_id="app-1", node_id="node-1", user_id="account-1", ) agent_app_candidates = AgentComposerService.get_agent_app_candidates( session=session, tenant_id="tenant-1", agent_id="agent-1", user_id="account-1" ) impact = AgentComposerService.calculate_impact( session=session, tenant_id="tenant-1", current_snapshot_id="version-1" ) assert workflow_candidates["variant"] == "workflow" assert workflow_candidates["allowed_node_job_candidates"]["previous_node_outputs"] == [] assert workflow_candidates["truncated"] is False assert agent_app_candidates["variant"] == "agent_app" assert agent_app_candidates["allowed_soul_candidates"]["dify_tools"] == [] assert impact["workflow_node_count"] == 2 assert impact["bindings"][1]["node_id"] == "node-2" def test_serialize_workflow_state_changes_lock_and_save_options( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): session = sqlite_session binding = WorkflowAgentNodeBinding( id="binding-1", tenant_id="tenant-1", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, agent_id="agent-1", current_snapshot_id="version-1", workflow_id="workflow-1", node_id="node-1", node_job_config='{"workflow_prompt":"do work"}', ) agent = Agent( id="agent-1", name="Analyst", description="Clarifies tenders", role="Tender Analyst", icon_type="emoji", icon="robot", icon_background="#F5F3FF", scope=AgentScope.ROSTER, source=AgentSource.ROSTER, status=AgentStatus.ACTIVE, ) version = AgentConfigSnapshot(id="version-1", version=1, config_snapshot='{"prompt":{"system_prompt":"x"}}') monkeypatch.setattr(AgentComposerService, "calculate_impact", lambda **kwargs: {"workflow_node_count": 1}) state = AgentComposerService._serialize_workflow_state( session=session, binding=binding, agent=agent, version=version ) assert state["soul_lock"]["locked"] is True assert state["agent"]["role"] == "Tender Analyst" assert state["agent"]["icon_type"] == "emoji" assert state["agent"]["icon"] == "robot" assert state["agent"]["icon_background"] == "#F5F3FF" assert "save_as_new_version" in state["save_options"] assert state["agent_soul"]["app_features"] == {} # Stage 4 §10.1 (D-3): binding with no declared_outputs → response surfaces # PRD defaults via effective_declared_outputs (DB row remains untouched). effective_names = [o["name"] for o in state["effective_declared_outputs"]] assert effective_names == ["text", "files", "json"] def test_serialize_workflow_state_passes_user_declared_outputs_through_effective( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): session = sqlite_session binding = WorkflowAgentNodeBinding( id="binding-1", tenant_id="tenant-1", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, agent_id="agent-1", current_snapshot_id="version-1", workflow_id="workflow-1", node_id="node-1", node_job_config=( '{"workflow_prompt":"work","declared_outputs":[{"name":"summary","type":"string","required":true}]}' ), ) agent = Agent( id="agent-1", name="Analyst", description="", scope=AgentScope.ROSTER, source=AgentSource.ROSTER, status=AgentStatus.ACTIVE, ) version = AgentConfigSnapshot(id="version-1", version=1, config_snapshot='{"prompt":{"system_prompt":"x"}}') monkeypatch.setattr(AgentComposerService, "calculate_impact", lambda **kwargs: {"workflow_node_count": 1}) state = AgentComposerService._serialize_workflow_state( session=session, binding=binding, agent=agent, version=version ) # When the user has declared outputs, effective_declared_outputs is the same # list (no defaults injected). effective = state["effective_declared_outputs"] assert [o["name"] for o in effective] == ["summary"] assert effective[0]["type"] == "string" assert effective[0]["required"] is True def test_serialize_workflow_state_includes_inline_debug_conversation_message_state( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, ): session = sqlite_session binding = WorkflowAgentNodeBinding( id="binding-1", tenant_id="tenant-1", binding_type=WorkflowAgentBindingType.INLINE_AGENT, agent_id="agent-1", current_snapshot_id="version-1", workflow_id="workflow-1", node_id="node-1", node_job_config='{"workflow_prompt":"work"}', ) agent = Agent( id="agent-1", name="Inline Agent", description="", scope=AgentScope.WORKFLOW_ONLY, source=AgentSource.WORKFLOW, status=AgentStatus.ACTIVE, backing_app_id="backing-app-1", ) version = AgentConfigSnapshot(id="version-1", version=1, config_snapshot='{"prompt":{"system_prompt":"x"}}') monkeypatch.setattr(AgentComposerService, "calculate_impact", lambda **kwargs: {"workflow_node_count": 1}) monkeypatch.setattr( AgentComposerService, "_workflow_inline_debug_conversation_id", lambda **kwargs: "debug-conversation-1", ) monkeypatch.setattr( roster_service.AgentRosterService, "count_agent_app_debug_conversation_messages", lambda self, *, conversation_id: 2, ) state = AgentComposerService._serialize_workflow_state( session=session, binding=binding, agent=agent, version=version, account_id="account-1", ) assert state["debug_conversation_id"] == "debug-conversation-1" assert state["debug_conversation_has_messages"] is True assert state["debug_conversation_message_count"] == 2 def test_composer_save_helpers_create_and_rebind_agents(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): session = sqlite_session workflow_agent = SimpleNamespace(id="inline-agent-1", active_config_snapshot_id="inline-version-1") roster_agent = SimpleNamespace( id="roster-agent-1", active_config_snapshot_id="roster-version-1", name="Roster", description="Source description", role="Source role", icon_type="emoji", icon="source", icon_background="#FFFFFF", scope=AgentScope.WORKFLOW_ONLY, ) create_roster_calls = [] copy_drive_calls = [] monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", lambda **kwargs: workflow_agent) def fake_create_roster_agent_for_composer(**kwargs): create_roster_calls.append(kwargs) return roster_agent monkeypatch.setattr( AgentComposerService, "_create_roster_agent_for_composer", fake_create_roster_agent_for_composer, ) monkeypatch.setattr( AgentComposerService, "_copy_agent_drive_rows", lambda **kwargs: copy_drive_calls.append(kwargs), ) monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent) monkeypatch.setattr( AgentComposerService, "_require_version", lambda **kwargs: AgentConfigSnapshot( id="source-version-1", tenant_id="tenant-1", agent_id="roster-agent-1", version=1, home_snapshot_id="home-source", config_snapshot='{"prompt":{"system_prompt":"old"}}', ), ) monkeypatch.setattr( AgentComposerService, "_create_config_version", lambda **kwargs: AgentConfigSnapshot(id="new-version-1", version=2), ) payload = ComposerSavePayload.model_validate( { "variant": ComposerVariant.WORKFLOW.value, "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value, "agent_soul": {"prompt": {"system_prompt": "new"}}, "node_job": {"workflow_prompt": "use prior output"}, "new_agent_name": "Copied Agent", "description": "Copied description", "role": "Copied role", "icon_type": "emoji", "icon": "copied", "icon_background": "#E0F2FE", } ) existing_binding = WorkflowAgentNodeBinding(agent_id="inline-agent-1", current_snapshot_id="inline-version-1") updated_binding = AgentComposerService._save_node_job_only( session=session, tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", node_id="node-1", account_id="account-1", binding=existing_binding, payload=payload, ) inline_binding = AgentComposerService._save_node_job_only( session=session, tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", node_id="node-2", account_id="account-1", binding=None, payload=payload, ) new_agent_binding = AgentComposerService._save_as_new_agent( session=session, tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", node_id="node-3", account_id="account-1", binding=None, payload=payload, ) save_to_roster_binding = AgentComposerService._save_to_roster( session=session, tenant_id="tenant-1", account_id="account-1", binding=WorkflowAgentNodeBinding( tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", node_id="node-4", agent_id="inline-agent-1", current_snapshot_id="inline-version-1", ), payload=payload, ) new_version_binding = AgentComposerService._save_as_new_version( session=session, tenant_id="tenant-1", account_id="account-1", binding=WorkflowAgentNodeBinding(agent_id="roster-agent-1", current_snapshot_id="source-version-1"), payload=payload, ) assert updated_binding.updated_by == "account-1" assert inline_binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT assert inline_binding.agent_id == "inline-agent-1" assert new_agent_binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT assert save_to_roster_binding.agent_id == "roster-agent-1" assert new_version_binding.current_snapshot_id == "new-version-1" assert create_roster_calls[0]["description"] == "Copied description" assert create_roster_calls[0]["role"] == "Copied role" assert create_roster_calls[0]["icon"] == "copied" assert create_roster_calls[0]["icon_background"] == "#E0F2FE" assert create_roster_calls[1]["description"] == "Copied description" assert create_roster_calls[1]["role"] == "Copied role" assert create_roster_calls[1]["icon"] == "copied" assert create_roster_calls[1]["icon_background"] == "#E0F2FE" copy_drive_calls[0].pop("session", None) assert copy_drive_calls == [ { "tenant_id": "tenant-1", "source_agent_id": "roster-agent-1", "target_agent_id": "roster-agent-1", "account_id": "account-1", "agent_soul": payload.agent_soul, "node_job": payload.node_job, } ] def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): session = sqlite_session inline_agent = SimpleNamespace( id="inline-agent-1", scope=AgentScope.WORKFLOW_ONLY, active_config_snapshot_id="inline-version-1", active_config_has_model=False, updated_by=None, ) current_snapshot = AgentConfigSnapshot( id="inline-version-1", tenant_id="tenant-1", agent_id="inline-agent-1", version=1, home_snapshot_id="home-inline-1", config_snapshot='{"prompt":{"system_prompt":"old"}}', ) next_snapshot = AgentConfigSnapshot( id="inline-version-2", tenant_id="tenant-1", agent_id="inline-agent-1", version=2, home_snapshot_id="home-inline-2", config_snapshot=AgentSoulConfig.model_validate( { "model": { "plugin_id": "langgenius/openai/openai", "model_provider": "openai", "model": "gpt-4o", }, "prompt": {"system_prompt": "new"}, } ), ) normal_draft = AgentConfigDraft( id="draft-1", tenant_id="tenant-1", agent_id="inline-agent-1", draft_type=AgentConfigDraftType.DRAFT, account_id=None, draft_owner_key="", base_snapshot_id="inline-version-1", home_snapshot_id="home-inline-1", config_snapshot=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "old"}}), ) monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: current_snapshot) monkeypatch.setattr(AgentComposerService, "_update_current_version", lambda **kwargs: next_snapshot) monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: inline_agent) monkeypatch.setattr(AgentComposerService, "_get_agent_draft", lambda **kwargs: normal_draft) binding = WorkflowAgentNodeBinding( tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version="draft", node_id="node-1", binding_type=WorkflowAgentBindingType.INLINE_AGENT, agent_id="inline-agent-1", current_snapshot_id="inline-version-1", ) payload = ComposerSavePayload.model_validate( { "variant": ComposerVariant.WORKFLOW.value, "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value, "agent_soul": { "model": { "plugin_id": "langgenius/openai/openai", "model_provider": "openai", "model": "gpt-4o", }, "prompt": {"system_prompt": "new"}, }, "node_job": {"workflow_prompt": "use prior output"}, } ) updated_binding = AgentComposerService._save_node_job_only( session=session, tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", node_id="node-1", account_id="account-1", binding=binding, payload=payload, ) assert updated_binding.current_snapshot_id == "inline-version-2" assert updated_binding.node_job_config_dict["workflow_prompt"] == "use prior output" assert updated_binding.updated_by == "account-1" assert inline_agent.active_config_snapshot_id == "inline-version-2" assert inline_agent.active_config_has_model is True assert inline_agent.updated_by == "account-1" assert normal_draft.id == "draft-1" assert normal_draft.base_snapshot_id == "inline-version-2" assert normal_draft.home_snapshot_id == "home-inline-2" assert normal_draft.config_snapshot_dict == next_snapshot.config_snapshot_dict assert normal_draft.updated_by == "account-1" def test_get_or_create_normal_agent_draft_rebases_stale_workflow_only_draft(sqlite_session: Session): session = sqlite_session agent = Agent( id="inline-agent-1", tenant_id="tenant-1", name="Inline", description="", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.WORKFLOW_ONLY, source=AgentSource.WORKFLOW, status=AgentStatus.ACTIVE, active_config_snapshot_id="inline-version-2", created_by="account-1", updated_by="account-2", ) draft = AgentConfigDraft( id="draft-1", tenant_id="tenant-1", agent_id=agent.id, draft_type=AgentConfigDraftType.DRAFT, account_id=None, draft_owner_key="", base_snapshot_id="inline-version-1", home_snapshot_id="home-inline-1", config_snapshot=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "old"}}), ) active_snapshot = AgentConfigSnapshot( id="inline-version-2", tenant_id="tenant-1", agent_id=agent.id, version=2, home_snapshot_id="home-inline-2", config_snapshot=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "new"}}), ) session.add_all([agent, draft, active_snapshot]) session.commit() resolved = AgentComposerService.get_or_create_normal_agent_draft( session=session, tenant_id="tenant-1", agent=agent, created_by="account-2", ) assert resolved is draft assert resolved.id == "draft-1" assert resolved.base_snapshot_id == "inline-version-2" assert resolved.home_snapshot_id == "home-inline-2" assert resolved.config_snapshot_dict == active_snapshot.config_snapshot_dict assert resolved.updated_by == "account-2" def test_get_or_create_normal_agent_draft_keeps_roster_draft_edits(sqlite_session: Session): session = sqlite_session agent = Agent( id="roster-agent-1", tenant_id="tenant-1", name="Roster", description="", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, active_config_snapshot_id="version-2", ) draft = AgentConfigDraft( id="draft-1", tenant_id="tenant-1", agent_id=agent.id, draft_type=AgentConfigDraftType.DRAFT, account_id=None, draft_owner_key="", base_snapshot_id="version-1", config_snapshot=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "local edit"}}), ) session.add_all([agent, draft]) session.commit() resolved = AgentComposerService.get_or_create_normal_agent_draft( session=session, tenant_id="tenant-1", agent=agent, created_by="account-1", ) assert resolved is draft assert resolved.base_snapshot_id == "version-1" assert resolved.config_snapshot_dict["prompt"]["system_prompt"] == "local edit" def test_node_job_only_switches_roster_binding_to_inline_agent( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): session = sqlite_session created_agent = SimpleNamespace(id="inline-agent-1", active_config_snapshot_id="inline-version-1") captured: dict[str, object] = {} def fake_create_workflow_only_agent(**kwargs): captured.update(kwargs) return created_agent monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", fake_create_workflow_only_agent) existing_node_job = WorkflowNodeJobConfig(workflow_prompt="keep the existing task") binding = WorkflowAgentNodeBinding( tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version="draft", node_id="node-1", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, agent_id="roster-agent-1", current_snapshot_id="roster-version-1", node_job_config=existing_node_job, created_by="account-1", updated_by="account-1", ) payload = ComposerSavePayload.model_validate( { "variant": ComposerVariant.WORKFLOW.value, "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value, "binding": {"binding_type": WorkflowAgentBindingType.INLINE_AGENT.value}, "agent_soul": {"prompt": {"system_prompt": "start from scratch"}}, } ) updated_binding = AgentComposerService._save_node_job_only( session=session, tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", node_id="node-1", account_id="account-1", binding=binding, payload=payload, ) assert updated_binding is binding assert binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT assert binding.agent_id == "inline-agent-1" assert binding.current_snapshot_id == "inline-version-1" assert binding.node_job_config is existing_node_job assert binding.updated_by == "account-1" assert captured["tenant_id"] == "tenant-1" assert captured["app_id"] == "app-1" assert captured["workflow_id"] == "workflow-1" assert captured["node_id"] == "node-1" assert captured["account_id"] == "account-1" assert captured["agent_soul"].prompt.system_prompt == "start from scratch" def test_node_job_only_rejects_start_from_scratch_with_existing_inline_binding_id(sqlite_session: Session): session = sqlite_session binding = WorkflowAgentNodeBinding( tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version="draft", node_id="node-1", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, agent_id="roster-agent-1", current_snapshot_id="roster-version-1", node_job_config=WorkflowNodeJobConfig(), ) payload = ComposerSavePayload.model_validate( { "variant": ComposerVariant.WORKFLOW.value, "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value, "binding": { "binding_type": WorkflowAgentBindingType.INLINE_AGENT.value, "agent_id": "existing-inline-agent", }, } ) with pytest.raises(ValueError, match="Start from Scratch"): AgentComposerService._save_node_job_only( session=session, tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", node_id="node-1", account_id="account-1", binding=binding, payload=payload, ) def test_node_job_only_rejects_inline_binding_pointing_to_roster_agent( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): session = sqlite_session current_snapshot = AgentConfigSnapshot( id="inline-version-1", tenant_id="tenant-1", agent_id="agent-1", version=1, config_snapshot='{"prompt":{"system_prompt":"old"}}', ) next_snapshot = AgentConfigSnapshot(id="inline-version-2", tenant_id="tenant-1", agent_id="agent-1", version=2) roster_agent = SimpleNamespace(id="agent-1", scope=AgentScope.ROSTER) monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: current_snapshot) monkeypatch.setattr(AgentComposerService, "_update_current_version", lambda **kwargs: next_snapshot) monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent) binding = WorkflowAgentNodeBinding( tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version="draft", node_id="node-1", binding_type=WorkflowAgentBindingType.INLINE_AGENT, agent_id="agent-1", current_snapshot_id="inline-version-1", ) payload = ComposerSavePayload.model_validate( { "variant": ComposerVariant.WORKFLOW.value, "save_strategy": ComposerSaveStrategy.NODE_JOB_ONLY.value, "agent_soul": {"prompt": {"system_prompt": "new"}}, } ) with pytest.raises(ValueError, match="workflow-only agent"): AgentComposerService._save_node_job_only( session=session, tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", node_id="node-1", account_id="account-1", binding=binding, payload=payload, ) def test_copy_workflow_composer_from_roster_creates_inline_agent_and_preserves_node_job( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, ): session = sqlite_session workflow = SimpleNamespace(id="workflow-1") node_job = WorkflowNodeJobConfig(workflow_prompt="keep this node task") binding = WorkflowAgentNodeBinding( tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version="draft", node_id="node-1", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, agent_id="roster-agent-1", current_snapshot_id="old-roster-version", node_job_config=node_job, ) roster_agent = Agent( id="roster-agent-1", tenant_id="tenant-1", name="Nadia", description="Clarification Drafter", role="Clarifies tenders", scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, active_config_snapshot_id="roster-version-2", ) source_version = AgentConfigSnapshot( id="roster-version-2", tenant_id="tenant-1", agent_id="roster-agent-1", version=2, config_snapshot='{"prompt":{"system_prompt":"copy me"}}', ) session.add( AgentConfigRevision( tenant_id="tenant-1", agent_id=roster_agent.id, current_snapshot_id=source_version.id, revision=1, operation=AgentConfigRevisionOperation.PUBLISH_DRAFT, ) ) session.commit() inline_agent = Agent( id="inline-agent-1", tenant_id="tenant-1", name="Nadia", description="Clarification Drafter", role="Clarifies tenders", scope=AgentScope.WORKFLOW_ONLY, source=AgentSource.WORKFLOW, status=AgentStatus.ACTIVE, active_config_snapshot_id="inline-version-1", ) captured: dict[str, object] = {} monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: workflow) monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: binding) monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent) monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: source_version) def fake_create_workflow_only_agent(**kwargs): captured["create"] = kwargs return inline_agent def fake_copy_drive_rows(**kwargs): captured["drive"] = kwargs monkeypatch.setattr(AgentComposerService, "_create_workflow_only_agent", fake_create_workflow_only_agent) monkeypatch.setattr(AgentComposerService, "_copy_agent_drive_rows", fake_copy_drive_rows) monkeypatch.setattr( AgentComposerService, "_serialize_workflow_state", lambda **kwargs: { "binding": { "binding_type": kwargs["binding"].binding_type.value, "agent_id": kwargs["binding"].agent_id, "current_snapshot_id": kwargs["binding"].current_snapshot_id, }, "node_job": kwargs["binding"].node_job_config_dict, }, ) state = AgentComposerService.copy_workflow_composer_from_roster( session=session, tenant_id="tenant-1", app_id="app-1", node_id="node-1", account_id="account-1", source_agent_id="roster-agent-1", source_snapshot_id="roster-version-2", ) assert state["binding"]["binding_type"] == WorkflowAgentBindingType.INLINE_AGENT.value assert state["binding"]["agent_id"] == "inline-agent-1" assert state["node_job"]["workflow_prompt"] == "keep this node task" assert binding.node_job_config is node_job create_kwargs = captured["create"] assert create_kwargs["agent_soul"].prompt.system_prompt == "copy me" assert create_kwargs["name"] == "Nadia" assert create_kwargs["role"] == "Clarifies tenders" drive_kwargs = captured["drive"] assert drive_kwargs["source_agent_id"] == "roster-agent-1" assert drive_kwargs["target_agent_id"] == "inline-agent-1" def test_copy_workflow_composer_from_roster_rejects_stale_source_snapshot( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): session = sqlite_session monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) monkeypatch.setattr( AgentComposerService, "_get_workflow_binding", lambda **kwargs: WorkflowAgentNodeBinding( tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version="draft", node_id="node-1", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, agent_id="roster-agent-1", current_snapshot_id="roster-version-1", node_job_config=WorkflowNodeJobConfig(), ), ) roster_agent = Agent( id="roster-agent-1", tenant_id="tenant-1", name="Nadia", scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, active_config_snapshot_id="roster-version-2", ) source_version = AgentConfigSnapshot( id="roster-version-2", tenant_id="tenant-1", agent_id="roster-agent-1", version=2, config_snapshot='{"prompt":{"system_prompt":"copy me"}}', ) session.add( AgentConfigRevision( tenant_id="tenant-1", agent_id=roster_agent.id, current_snapshot_id=source_version.id, revision=1, operation=AgentConfigRevisionOperation.PUBLISH_DRAFT, ) ) session.commit() monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: roster_agent) monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: source_version) with pytest.raises(AgentVersionConflictError): AgentComposerService.copy_workflow_composer_from_roster( session=session, tenant_id="tenant-1", app_id="app-1", node_id="node-1", account_id="account-1", source_agent_id="roster-agent-1", source_snapshot_id="roster-version-1", ) def test_copy_workflow_composer_from_roster_rejects_unpublished_source( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): session = sqlite_session binding = WorkflowAgentNodeBinding( tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version="draft", node_id="node-1", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, agent_id="roster-agent-1", current_snapshot_id="roster-version-1", node_job_config=WorkflowNodeJobConfig(), ) source_agent = Agent( id="roster-agent-1", tenant_id="tenant-1", name="Unpublished import", scope=AgentScope.ROSTER, source=AgentSource.IMPORTED, app_id="agent-app-1", status=AgentStatus.ACTIVE, active_config_snapshot_id="roster-version-1", ) monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: binding) monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: source_agent) require_version = MagicMock(side_effect=AssertionError("unpublished source must fail before loading snapshot")) monkeypatch.setattr(AgentComposerService, "_require_version", require_version) with pytest.raises(InvalidComposerConfigError, match="published config snapshot"): AgentComposerService.copy_workflow_composer_from_roster( session=session, tenant_id="tenant-1", app_id="app-1", node_id="node-1", account_id="account-1", source_agent_id="roster-agent-1", ) require_version.assert_not_called() assert not session.new assert not session.dirty def test_copy_workflow_composer_from_roster_is_idempotent_when_already_inline( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): inline_binding = WorkflowAgentNodeBinding( tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version="draft", node_id="node-1", binding_type=WorkflowAgentBindingType.INLINE_AGENT, agent_id="inline-agent-1", current_snapshot_id="inline-version-1", ) inline_agent = Agent( id="inline-agent-1", tenant_id="tenant-1", name="Inline", scope=AgentScope.WORKFLOW_ONLY, source=AgentSource.WORKFLOW, status=AgentStatus.ACTIVE, active_config_snapshot_id="inline-version-1", ) inline_version = AgentConfigSnapshot( id="inline-version-1", tenant_id="tenant-1", agent_id="inline-agent-1", version=1, config_snapshot='{"prompt":{"system_prompt":"inline"}}', ) serialize_calls = [] session = sqlite_session monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: inline_binding) monkeypatch.setattr(AgentComposerService, "_get_agent_if_present", lambda **kwargs: inline_agent) monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: inline_version) def serialize_workflow_state(**kwargs): serialize_calls.append(kwargs) return {"binding_type": kwargs["binding"].binding_type.value} monkeypatch.setattr(AgentComposerService, "_serialize_workflow_state", serialize_workflow_state) state = AgentComposerService.copy_workflow_composer_from_roster( session=session, tenant_id="tenant-1", app_id="app-1", node_id="node-1", account_id="account-1", source_agent_id="roster-agent-1", idempotency_key="same-click", ) assert state == {"binding_type": WorkflowAgentBindingType.INLINE_AGENT.value} assert serialize_calls[0]["account_id"] == "account-1" @pytest.mark.parametrize( ("binding_agent_id", "binding_type", "source_scope", "source_status", "expected_message"), [ ( "roster-agent-1", WorkflowAgentBindingType.INLINE_AGENT, AgentScope.ROSTER, AgentStatus.ACTIVE, "must be bound to a roster agent", ), ( "other-agent", WorkflowAgentBindingType.ROSTER_AGENT, AgentScope.ROSTER, AgentStatus.ACTIVE, "does not match", ), ( "roster-agent-1", WorkflowAgentBindingType.ROSTER_AGENT, AgentScope.WORKFLOW_ONLY, AgentStatus.ACTIVE, "must be an active roster agent", ), ( "roster-agent-1", WorkflowAgentBindingType.ROSTER_AGENT, AgentScope.ROSTER, AgentStatus.ARCHIVED, "must be an active roster agent", ), ], ) def test_copy_workflow_composer_from_roster_rejects_invalid_source_binding( monkeypatch: pytest.MonkeyPatch, binding_agent_id: str, binding_type: WorkflowAgentBindingType, source_scope: AgentScope, source_status: AgentStatus, expected_message: str, sqlite_session: Session, ): session = sqlite_session binding = WorkflowAgentNodeBinding( tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version="draft", node_id="node-1", binding_type=binding_type, agent_id=binding_agent_id, current_snapshot_id="version-1", node_job_config=WorkflowNodeJobConfig(), ) source_agent = Agent( id="roster-agent-1", tenant_id="tenant-1", name="Source", scope=source_scope, source=AgentSource.AGENT_APP, status=source_status, active_config_snapshot_id="version-1", ) monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", lambda **kwargs: SimpleNamespace(id="workflow-1")) monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", lambda **kwargs: binding) monkeypatch.setattr(AgentComposerService, "_require_agent", lambda **kwargs: source_agent) with pytest.raises(InvalidComposerConfigError, match=expected_message): AgentComposerService.copy_workflow_composer_from_roster( session=session, tenant_id="tenant-1", app_id="app-1", node_id="node-1", account_id="account-1", source_agent_id="roster-agent-1", ) def test_copy_agent_drive_rows_copies_skill_prefix_and_files(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): session = sqlite_session skill_row = AgentDriveFile( tenant_id="tenant-1", agent_id="roster-agent-1", key="tender-analyzer/SKILL.md", file_kind="tool_file", file_id="tool-file-1", value_owned_by_drive=True, is_skill=True, skill_metadata='{"name":"Tender Analyzer"}', size=10, mime_type="text/markdown", ) script_row = AgentDriveFile( tenant_id="tenant-1", agent_id="roster-agent-1", key="tender-analyzer/scripts/run.sh", file_kind="tool_file", file_id="tool-file-2", value_owned_by_drive=True, size=20, mime_type="text/x-shellscript", ) file_row = AgentDriveFile( tenant_id="tenant-1", agent_id="roster-agent-1", key="files/qna.pdf", file_kind="upload_file", file_id="upload-file-1", value_owned_by_drive=False, size=30, mime_type="application/pdf", ) session.add_all([skill_row, script_row, file_row]) session.commit() agent_soul = AgentSoulConfig.model_validate( { "prompt": { "system_prompt": "[§skill:tender-analyzer/SKILL.md:Tender Analyzer§]", }, } ) node_job = WorkflowNodeJobConfig.model_validate( {"metadata": {"file_refs": [{"name": "qna.pdf", "drive_key": "files/qna.pdf"}]}} ) AgentComposerService._copy_agent_drive_rows( session=session, tenant_id="tenant-1", source_agent_id="roster-agent-1", target_agent_id="inline-agent-1", account_id="account-1", agent_soul=agent_soul, node_job=node_job, ) session.flush() copied = list( session.scalars( select(AgentDriveFile).where( AgentDriveFile.tenant_id == "tenant-1", AgentDriveFile.agent_id == "inline-agent-1", ) ) ) assert {row.key for row in copied} == { "tender-analyzer/SKILL.md", "tender-analyzer/scripts/run.sh", "files/qna.pdf", } assert {row.agent_id for row in copied} == {"inline-agent-1"} copied_by_key = {row.key: row for row in copied} assert copied_by_key["tender-analyzer/SKILL.md"].file_id == "tool-file-1" assert copied_by_key["tender-analyzer/SKILL.md"].is_skill is True assert copied_by_key["files/qna.pdf"].value_owned_by_drive is False def test_copy_agent_drive_rows_skips_when_no_referenced_drive_keys( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): session = sqlite_session agent_soul = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "No drive mentions."}}) AgentComposerService._copy_agent_drive_rows( session=session, tenant_id="tenant-1", source_agent_id="roster-agent-1", target_agent_id="inline-agent-1", account_id="account-1", agent_soul=agent_soul, ) assert not session.new def test_copy_agent_drive_rows_skips_existing_target_keys(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): session = sqlite_session source_row = AgentDriveFile( tenant_id="tenant-1", agent_id="roster-agent-1", key="files/qna.pdf", file_kind="upload_file", file_id="upload-file-1", value_owned_by_drive=False, size=30, mime_type="application/pdf", ) target_row = AgentDriveFile( tenant_id="tenant-1", agent_id="inline-agent-1", key=source_row.key, file_kind=source_row.file_kind, file_id=source_row.file_id, value_owned_by_drive=source_row.value_owned_by_drive, size=source_row.size, mime_type=source_row.mime_type, ) session.add_all([source_row, target_row]) session.commit() agent_soul = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "[§file:files/qna.pdf:qna.pdf§]"}}) AgentComposerService._copy_agent_drive_rows( session=session, tenant_id="tenant-1", source_agent_id="roster-agent-1", target_agent_id="inline-agent-1", account_id="account-1", agent_soul=agent_soul, ) session.flush() target_rows = list( session.scalars( select(AgentDriveFile).where( AgentDriveFile.tenant_id == "tenant-1", AgentDriveFile.agent_id == "inline-agent-1", ) ) ) assert [row.key for row in target_rows] == ["files/qna.pdf"] def test_drive_copy_scopes_include_declared_output_benchmark_files(): agent_soul = AgentSoulConfig.model_validate( { "prompt": { "system_prompt": ( "[§file:files/source.pdf:source.pdf§] " "[§knowledge:dataset-1:Docs§] " "[§skill:tender-analyzer/SKILL.md:Tender Analyzer§]" ) }, } ) node_job = WorkflowNodeJobConfig.model_validate( { "declared_outputs": [ { "name": "qna_report", "type": "file", "check": { "enabled": True, "prompt": "Compare the generated file with the benchmark.", "benchmark_file_ref": {"name": "expected.pdf", "drive_key": "files/expected.pdf"}, }, }, { "name": "summary", "type": "string", "check": {"enabled": False, "benchmark_file_ref": {"drive_key": "files/ignored.pdf"}}, }, ], } ) exact_keys, prefixes = AgentComposerService._drive_copy_scopes_from_agent_configs( agent_soul=agent_soul, node_job=node_job, ) assert exact_keys == {"files/source.pdf", "files/expected.pdf"} assert prefixes == {"tender-analyzer/"} def test_composer_create_agents_syncs_active_config_has_model( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, ) -> None: session = sqlite_session created_apps = [] hidden_backing_apps = [] backing_agent = Agent( id="roster-agent-1", tenant_id="tenant-1", name="Ready Agent", scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, app_id="app-agent-1", active_config_snapshot_id="empty-version-1", ) class FakeAppService: def create_app(self, tenant_id, params, account, *, session): created_apps.append((tenant_id, params, account)) return SimpleNamespace(id="app-agent-1") class FakeAgentRosterService: def __init__(self, session): self.session = session def create_hidden_backing_app_for_workflow_agent(self, **kwargs): hidden_backing_apps.append(kwargs) return SimpleNamespace(id="hidden-app-1") def get_app_backing_agent(self, *, tenant_id, app_id): assert tenant_id == "tenant-1" assert app_id == "app-agent-1" return backing_agent monkeypatch.setattr(composer_service, "AppService", FakeAppService) monkeypatch.setattr(composer_service, "AgentRosterService", FakeAgentRosterService) monkeypatch.setattr(AgentComposerService, "_require_account", lambda **kwargs: SimpleNamespace(id="account-1")) monkeypatch.setattr( AgentComposerService, "_require_version", lambda **kwargs: SimpleNamespace( id="empty-version-1", tenant_id="tenant-1", agent_id="roster-agent-1", home_snapshot_id=None, ), ) create_config_version = MagicMock(return_value=SimpleNamespace(id="version-with-model")) monkeypatch.setattr(AgentComposerService, "_create_config_version", create_config_version) workflow_agent = AgentComposerService._create_workflow_only_agent( session=session, tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", node_id="node-1", account_id="account-1", agent_soul=_agent_soul_with_model(), ) roster_agent = AgentComposerService._create_roster_agent_for_composer( session=session, tenant_id="tenant-1", account_id="account-1", name="Ready Agent", agent_soul=_agent_soul_with_model(), operation=AgentConfigRevisionOperation.CREATE_VERSION, version_note=None, ) assert workflow_agent.active_config_snapshot_id == "version-with-model" assert workflow_agent.active_config_has_model is True assert workflow_agent.backing_app_id == "hidden-app-1" assert create_config_version.call_count == 2 assert all(call.kwargs["home_snapshot_id"] is None for call in create_config_version.call_args_list) assert hidden_backing_apps[0]["name"] == "Workflow Agent node-1" assert roster_agent.active_config_snapshot_id == "version-with-model" assert roster_agent.active_config_has_model is True assert roster_agent.source == AgentSource.AGENT_APP assert roster_agent.app_id == "app-agent-1" created_tenant_id, created_params, created_account = created_apps[0] assert created_tenant_id == "tenant-1" assert created_params.mode == "agent" assert created_params.name == "Ready Agent" assert created_account.id == "account-1" def test_composer_require_account(sqlite_session: Session): session = sqlite_session account = Account(name="Tester", email="tester@example.com") account.id = "account-1" session.add(account) session.commit() assert AgentComposerService._require_account(session=session, account_id="account-1") is account def test_composer_require_account_raises_when_missing(sqlite_session: Session): with pytest.raises(ValueError, match="Account not found"): AgentComposerService._require_account(session=sqlite_session, account_id="missing-account") def test_composer_create_roster_agent_maps_name_conflict_without_owning_rollback( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): session = sqlite_session transaction = session.begin() class FakeAppService: def create_app(self, tenant_id, params, account, *, session): raise IntegrityError("insert apps", params, Exception("duplicate")) monkeypatch.setattr(composer_service, "AppService", FakeAppService) monkeypatch.setattr(AgentComposerService, "_require_account", lambda **kwargs: SimpleNamespace(id="account-1")) with pytest.raises(AgentNameConflictError): AgentComposerService._create_roster_agent_for_composer( session=session, tenant_id="tenant-1", account_id="account-1", name="Duplicate Agent", agent_soul=_agent_soul_with_model(), operation=AgentConfigRevisionOperation.CREATE_VERSION, version_note=None, ) assert transaction.is_active def test_composer_create_roster_agent_raises_when_backing_agent_missing( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): session = sqlite_session class FakeAppService: def create_app(self, tenant_id, params, account, *, session): return SimpleNamespace(id="app-agent-1") class FakeAgentRosterService: def __init__(self, session): self.session = session def get_app_backing_agent(self, *, tenant_id, app_id): return None monkeypatch.setattr(composer_service, "AppService", FakeAppService) monkeypatch.setattr(composer_service, "AgentRosterService", FakeAgentRosterService) monkeypatch.setattr(AgentComposerService, "_require_account", lambda **kwargs: SimpleNamespace(id="account-1")) with pytest.raises(AgentNotFoundError): AgentComposerService._create_roster_agent_for_composer( session=session, tenant_id="tenant-1", account_id="account-1", name="Missing Backing Agent", agent_soul=_agent_soul_with_model(), operation=AgentConfigRevisionOperation.CREATE_VERSION, version_note=None, ) def test_agent_app_draft_match_does_not_mark_create_version_as_published( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): agent_soul = AgentSoulConfig() agent = Agent( id="agent-1", tenant_id="tenant-1", source=AgentSource.AGENT_APP, active_config_snapshot_id="snapshot-1", ) snapshot = SimpleNamespace(config_snapshot_dict=agent_soul, home_snapshot_id="home-1") session = sqlite_session monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: snapshot) assert ( AgentComposerService._agent_soul_matches_active_config( session=session, tenant_id="tenant-1", agent=agent, agent_soul=agent_soul, home_snapshot_id="home-1", ) is False ) def test_agent_app_draft_match_marks_publish_visible_revision_as_published( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): agent_soul = AgentSoulConfig() agent = Agent( id="agent-1", tenant_id="tenant-1", source=AgentSource.AGENT_APP, active_config_snapshot_id="snapshot-1", ) snapshot = SimpleNamespace(config_snapshot_dict=agent_soul, home_snapshot_id="home-1") session = sqlite_session session.add( AgentConfigRevision( tenant_id=agent.tenant_id, agent_id=agent.id, current_snapshot_id=agent.active_config_snapshot_id, revision=1, operation=AgentConfigRevisionOperation.PUBLISH_DRAFT, ) ) session.commit() monkeypatch.setattr(AgentComposerService, "_get_version_if_present", lambda **kwargs: snapshot) assert ( AgentComposerService._agent_soul_matches_active_config( session=session, tenant_id="tenant-1", agent=agent, agent_soul=agent_soul, home_snapshot_id="home-1", ) is True ) def test_composer_version_helpers_and_lookup_errors(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): session = sqlite_session agent = _agent() initial_snapshot = _snapshot(snapshot_id="version-1") initial_revision = AgentConfigRevision( tenant_id="tenant-1", agent_id=agent.id, current_snapshot_id=initial_snapshot.id, revision=1, operation=AgentConfigRevisionOperation.CREATE_VERSION, ) workflow_row = _workflow() session.add_all([agent, initial_snapshot, initial_revision, workflow_row]) session.commit() agent_soul = AgentSoulConfig.model_validate({"prompt": {"system_prompt": "new"}}) version = AgentComposerService._create_config_version( session=session, tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", agent_soul=agent_soul, operation=AgentConfigRevisionOperation.SAVE_NEW_VERSION, version_note="note", home_snapshot_id="home-1", ) updated_snapshot = AgentComposerService._update_current_version( session=session, current_snapshot=AgentConfigSnapshot( id="version-1", tenant_id="tenant-1", agent_id="agent-1", version=1, home_snapshot_id="home-1", config_snapshot='{"prompt":{"system_prompt":"old"}}', ), account_id="account-1", agent_soul=agent_soul, operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION, version_note="updated", ) workflow = AgentComposerService._get_draft_workflow(session=session, tenant_id="tenant-1", app_id="app-1") with pytest.raises(ValueError): AgentComposerService._get_draft_workflow(session=session, tenant_id="tenant-1", app_id="missing") assert ( AgentComposerService._require_agent(session=session, tenant_id="tenant-1", agent_id="agent-1").id == "agent-1" ) with pytest.raises(composer_service.AgentNotFoundError): AgentComposerService._require_agent(session=session, tenant_id="tenant-1", agent_id=None) assert AgentComposerService._get_agent_if_present(session=session, tenant_id="tenant-1", agent_id="missing") is None assert ( AgentComposerService._require_version( session=session, tenant_id="tenant-1", agent_id="agent-1", version_id="version-1", ).id == "version-1" ) with pytest.raises(composer_service.AgentVersionNotFoundError): AgentComposerService._require_version( session=session, tenant_id="tenant-1", agent_id="agent-1", version_id="missing", ) assert version.version == 2 assert updated_snapshot.version == 3 assert version.home_snapshot_id == "home-1" assert updated_snapshot.home_snapshot_id == "home-1" assert workflow.id == "workflow-1" def test_composer_current_version_and_error_paths(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): session = sqlite_session payload = ComposerSavePayload.model_validate( { "variant": ComposerVariant.WORKFLOW.value, "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, "agent_soul": {"prompt": {"system_prompt": "updated"}}, "node_job": {"workflow_prompt": "job"}, } ) binding = WorkflowAgentNodeBinding(agent_id="agent-1", current_snapshot_id="version-1") version = AgentConfigSnapshot( id="version-1", tenant_id="tenant-1", agent_id="agent-1", version=1, home_snapshot_id="home-1", config_snapshot='{"prompt":{"system_prompt":"old"}}', ) monkeypatch.setattr(AgentComposerService, "_require_version", lambda **kwargs: version) monkeypatch.setattr( AgentComposerService, "_require_agent", lambda **kwargs: SimpleNamespace(updated_by=None, active_config_is_published=False), ) result = AgentComposerService._save_to_current_version( session=session, tenant_id="tenant-1", account_id="account-1", binding=binding, payload=payload, ) assert result.updated_by == "account-1" assert result.current_snapshot_id != "version-1" created_version = session.get(AgentConfigSnapshot, result.current_snapshot_id) assert created_version is not None assert created_version.home_snapshot_id == "home-1" with pytest.raises(ValueError): AgentComposerService._require_binding(None) with pytest.raises(ValueError): AgentComposerService._save_as_new_agent( session=session, tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", node_id="node-1", account_id="account-1", binding=None, payload=ComposerSavePayload.model_validate( { "variant": ComposerVariant.WORKFLOW.value, "save_strategy": ComposerSaveStrategy.SAVE_AS_NEW_AGENT.value, } ), ) def test_roster_list_and_invite_options(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): session = sqlite_session created_at = datetime(2026, 1, 2, 3, 4, 5, tzinfo=UTC) updated_at = datetime(2026, 1, 3, 3, 4, 5, tzinfo=UTC) version_created_at = datetime(2026, 1, 4, 3, 4, 5, tzinfo=UTC) agent = Agent( id="agent-1", tenant_id="tenant-1", name="Analyst", description="", role="researcher", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, ) agent.created_at = created_at agent.updated_at = updated_at version = AgentConfigSnapshot( id="version-1", tenant_id="tenant-1", agent_id="agent-1", version=1, config_snapshot=_agent_soul_with_model(), ) version.created_at = version_created_at agent.active_config_snapshot_id = "version-1" agent.active_config_has_model = True agent.active_config_is_published = True unconfigured_agent = Agent( id="agent-2", tenant_id="tenant-1", name="Draft Agent", description="", role="draft", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, ) unconfigured_agent.active_config_snapshot_id = "version-2" unconfigured_agent.active_config_has_model = False unconfigured_agent.updated_at = datetime(2026, 1, 1, 3, 4, 5, tzinfo=UTC) unconfigured_version = AgentConfigSnapshot( id="version-2", tenant_id="tenant-1", agent_id="agent-2", version=1, config_snapshot=AgentSoulConfig(), ) workflow_row = _workflow() binding = WorkflowAgentNodeBinding( tenant_id="tenant-1", app_id="app-1", workflow_id=workflow_row.id, workflow_version=Workflow.VERSION_DRAFT, node_id="node-1", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, agent_id=agent.id, current_snapshot_id=version.id, node_job_config=WorkflowNodeJobConfig(), ) publish_revision = AgentConfigRevision( tenant_id="tenant-1", agent_id=agent.id, current_snapshot_id=version.id, revision=1, operation=AgentConfigRevisionOperation.PUBLISH_DRAFT, ) session.add_all([agent, unconfigured_agent, version, unconfigured_version, workflow_row, binding, publish_revision]) session.commit() service = AgentRosterService(session) monkeypatch.setattr(service, "_load_published_references_by_agent_id", lambda **kwargs: {}) monkeypatch.setattr(service, "_load_reference_counts_by_agent_id", lambda **kwargs: {"agent-1": 1}) listed = service.list_roster_agents(tenant_id="tenant-1", page=1, limit=20) invited = service.list_invite_options(tenant_id="tenant-1", page=1, limit=20, app_id="app-1") assert [item["id"] for item in listed["data"]] == ["agent-1", "agent-2"] assert [item["id"] for item in invited["data"]] == ["agent-1"] assert invited["total"] == 1 assert listed["data"][0]["active_config_snapshot"]["id"] == "version-1" assert listed["data"][0]["role"] == "researcher" assert listed["data"][0]["created_at"] == int(created_at.timestamp()) assert listed["data"][0]["updated_at"] == int(updated_at.timestamp()) assert listed["data"][0]["active_config_snapshot"]["created_at"] == int(version_created_at.timestamp()) assert listed["data"][0]["active_config_is_published"] is True assert listed["data"][0]["reference_count"] == 1 assert listed["data"][1]["active_config_is_published"] is False assert invited["data"][0]["is_in_current_workflow"] is True assert invited["data"][0]["existing_node_ids"] == ["node-1"] def test_invite_options_uses_db_filtered_pagination(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): session = sqlite_session configured_agent = Agent( id="agent-2", tenant_id="tenant-1", name="Ready Agent", description="", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, active_config_snapshot_id="version-2", active_config_has_model=True, active_config_is_published=True, ) version = AgentConfigSnapshot( id="version-2", tenant_id="tenant-1", agent_id="agent-2", version=1, config_snapshot=_agent_soul_with_model(), ) publish_revision = AgentConfigRevision( tenant_id="tenant-1", agent_id=configured_agent.id, current_snapshot_id=version.id, revision=1, operation=AgentConfigRevisionOperation.PUBLISH_DRAFT, ) session.add_all([configured_agent, version, publish_revision]) session.commit() service = AgentRosterService(session) monkeypatch.setattr(service, "_load_published_references_by_agent_id", lambda **kwargs: {}) monkeypatch.setattr(service, "_load_reference_counts_by_agent_id", lambda **kwargs: {}) result = service.list_invite_options(tenant_id="tenant-1", page=1, limit=1) assert result["total"] == 1 assert result["has_more"] is False assert [item["id"] for item in result["data"]] == ["agent-2"] def test_active_config_is_published_flags_use_stored_agent_state(sqlite_session: Session): agent = Agent( id="agent-1", tenant_id="tenant-1", name="Published", 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=True, ) draft_agent = Agent( id="agent-2", tenant_id="tenant-1", name="Draft", description="", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, active_config_snapshot_id=None, active_config_is_published=True, ) dirty_agent = Agent( id="agent-3", tenant_id="tenant-1", name="Dirty", description="", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, active_config_snapshot_id="version-3", active_config_is_published=False, ) service = AgentRosterService(sqlite_session) flags = service.load_active_config_is_published_by_agent_id( tenant_id="tenant-1", agents=[agent, draft_agent, dirty_agent] ) assert flags == {"agent-1": True, "agent-2": False, "agent-3": False} assert service.active_config_is_published(tenant_id="tenant-1", agent=agent) is True assert AgentRosterService(sqlite_session).load_active_config_is_published_by_agent_id( tenant_id="tenant-1", agents=[draft_agent], ) == {"agent-2": False} def test_active_config_is_published_skips_empty_agent_ids(sqlite_session: Session): empty_id_agent = Agent( id="", tenant_id="tenant-1", name="Broken", description="", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, active_config_snapshot_id=None, ) session = sqlite_session assert ( AgentRosterService(session).load_active_config_is_published_by_agent_id( tenant_id="tenant-1", agents=[empty_id_agent], ) == {} ) def test_load_app_backing_agents_skips_empty_agent_ids(sqlite_session: Session): session = sqlite_session valid_agent = Agent( id="agent-1", tenant_id="tenant-1", name="Valid", description="", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, app_id="app-1", status=AgentStatus.ACTIVE, ) empty_id_agent = Agent( id="", tenant_id="tenant-1", name="Broken", description="", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, app_id="app-2", status=AgentStatus.ACTIVE, ) session.add_all([valid_agent, empty_id_agent]) session.commit() result = AgentRosterService(session).load_app_backing_agents_by_app_id( tenant_id="tenant-1", app_ids=["app-1", "app-2"], ) assert result == {"app-1": valid_agent} def test_published_references_include_app_display_fields_and_sort_by_updated_at(sqlite_session: Session): session = sqlite_session recent_updated_at = datetime(2026, 1, 7, 3, 4, 5, tzinfo=UTC) stale_updated_at = datetime(2026, 1, 6, 3, 4, 5, tzinfo=UTC) bindings = [ WorkflowAgentNodeBinding( tenant_id="tenant-1", agent_id="agent-1", app_id="app-stale", workflow_id="workflow-stale", workflow_version="published-stale", node_id="node-b", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, current_snapshot_id="version-1", node_job_config=WorkflowNodeJobConfig(), ), WorkflowAgentNodeBinding( tenant_id="tenant-1", agent_id="agent-1", app_id="app-recent", workflow_id="workflow-recent", workflow_version="published-recent", node_id="node-a", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, current_snapshot_id="version-1", node_job_config=WorkflowNodeJobConfig(), ), ] stale_app = _app(app_id="app-stale", name="Stale Workflow", mode=AppMode.ADVANCED_CHAT) stale_app.workflow_id = "workflow-stale" stale_app.icon = "old" stale_app.icon_background = "#F3F4F6" stale_app.updated_at = stale_updated_at recent_app = _app(app_id="app-recent", name="Recent Workflow", mode=AppMode.ADVANCED_CHAT) recent_app.workflow_id = "workflow-recent" recent_app.icon_type = IconType.IMAGE recent_app.icon = "upload-file-id" recent_app.icon_background = "#E0F2FE" recent_app.updated_at = recent_updated_at session.add_all([*bindings, stale_app, recent_app]) session.commit() service = AgentRosterService(session) result = service._load_published_references_by_agent_id(tenant_id="tenant-1", agent_ids=["agent-1"]) references = result["agent-1"] assert [item["app_id"] for item in references] == ["app-recent", "app-stale"] assert references[0]["app_icon_type"] == "image" assert references[0]["app_icon"] == "upload-file-id" assert references[0]["app_icon_background"] == "#E0F2FE" assert references[0]["app_updated_at"] == int(recent_updated_at.timestamp()) assert references[0]["workflow_version"] == "published-recent" def test_reference_counts_include_draft_and_published_bindings_once_per_app(sqlite_session: Session): session = sqlite_session bindings = [ WorkflowAgentNodeBinding( tenant_id="tenant-1", agent_id="agent-1", app_id="app-1", workflow_id="workflow-draft", workflow_version=Workflow.VERSION_DRAFT, node_id="node-draft", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, current_snapshot_id="version-1", node_job_config=WorkflowNodeJobConfig(), ), WorkflowAgentNodeBinding( tenant_id="tenant-1", agent_id="agent-1", app_id="app-1", workflow_id="workflow-published", workflow_version="v1", node_id="node-published", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, current_snapshot_id="version-1", node_job_config=WorkflowNodeJobConfig(), ), WorkflowAgentNodeBinding( tenant_id="tenant-1", agent_id="agent-1", app_id="app-2", workflow_id="workflow-stale", workflow_version="old-version", node_id="node-stale", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, current_snapshot_id="version-1", node_job_config=WorkflowNodeJobConfig(), ), ] app_one = _app(app_id="app-1", mode=AppMode.ADVANCED_CHAT) app_one.workflow_id = "workflow-published" app_two = _app(app_id="app-2", mode=AppMode.ADVANCED_CHAT) app_two.workflow_id = "workflow-stale" draft_workflow = _workflow(workflow_id="workflow-draft", app_id="app-1") published_workflow = _workflow(workflow_id="workflow-published", app_id="app-1") published_workflow.version = "v1" stale_workflow = _workflow(workflow_id="workflow-stale", app_id="app-2") stale_workflow.version = "current-version" session.add_all([*bindings, app_one, app_two, draft_workflow, published_workflow, stale_workflow]) session.commit() service = AgentRosterService(session) result = service._load_reference_counts_by_agent_id(tenant_id="tenant-1", agent_ids=["agent-1"]) assert result == {"agent-1": 1} def test_roster_update_archive_versions_and_detail(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): session = sqlite_session listed_version = AgentConfigSnapshot( id="version-4", tenant_id="tenant-1", agent_id="agent-1", version=4, config_snapshot=AgentSoulConfig(), ) listed_version_created_at = datetime(2026, 1, 5, 3, 4, 5, tzinfo=UTC) listed_version.created_at = listed_version_created_at older_listed_version = AgentConfigSnapshot( id="version-2", tenant_id="tenant-1", agent_id="agent-1", version=2, config_snapshot='{"prompt":{}}', ) older_listed_version.created_at = datetime(2026, 1, 4, 3, 4, 5, tzinfo=UTC) revision_created_at = datetime(2026, 1, 6, 3, 4, 5, tzinfo=UTC) revision = AgentConfigRevision( id="revision-1", tenant_id="tenant-1", agent_id="agent-1", previous_snapshot_id=None, current_snapshot_id="version-2", revision=1, operation=AgentConfigRevisionOperation.SAVE_NEW_VERSION, summary=None, version_note=None, created_by="account-1", ) revision.created_at = revision_created_at listed_revision = AgentConfigRevision( tenant_id="tenant-1", agent_id="agent-1", previous_snapshot_id="version-2", current_snapshot_id="version-4", revision=2, operation=AgentConfigRevisionOperation.SAVE_NEW_VERSION, created_by="account-1", ) agent = Agent( id="agent-1", tenant_id="tenant-1", name="Analyst", description="old", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, ) session.add_all([agent, listed_version, older_listed_version, revision, listed_revision]) session.commit() service = AgentRosterService(session) retire_snapshots = MagicMock(return_value=[]) monkeypatch.setattr(AgentHomeSnapshotService, "retire_all_for_agent", retire_snapshots) monkeypatch.setattr( service, "get_roster_agent_detail", lambda **kwargs: {"id": kwargs["agent_id"], "description": agent.description}, ) updated = service.update_roster_agent( tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", payload=roster_service.RosterAgentUpdatePayload(description="new"), ) service.archive_roster_agent(tenant_id="tenant-1", agent_id="agent-1", account_id="account-1") versions = service.list_agent_versions(tenant_id="tenant-1", agent_id="agent-1") detail = service.get_agent_version_detail(tenant_id="tenant-1", agent_id="agent-1", version_id="version-2") assert updated["description"] == "new" assert agent.status == AgentStatus.ARCHIVED retire_snapshots.assert_called_once_with(session=session, tenant_id="tenant-1", agent_id="agent-1") assert versions[0]["id"] == "version-4" assert versions[0]["version"] == 2 assert versions[0]["display_version"] == 2 assert versions[0]["snapshot_version"] == 4 assert versions[1]["id"] == "version-2" assert versions[1]["version"] == 1 assert versions[1]["snapshot_version"] == 2 assert versions[0]["created_at"] == int(listed_version_created_at.timestamp()) assert detail["version"] == 1 assert detail["display_version"] == 1 assert detail["snapshot_version"] == 2 assert detail["config_snapshot"] == {"prompt": {}} assert detail["created_at"] == int(older_listed_version.created_at.timestamp()) assert detail["revisions"][0]["created_at"] == int(revision_created_at.timestamp()) def test_roster_archive_retires_then_commits_before_enqueue( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: session = sqlite_session service = AgentRosterService(session) agent = _agent() binding = AgentWorkspaceBinding( id="binding-1", tenant_id=agent.tenant_id, app_id="app-1", workspace_id="workspace-1", agent_id=agent.id, agent_config_version_id="version-1", agent_config_version_kind=AgentConfigVersionKind.SNAPSHOT, backend_binding_ref="backend-binding-1", ) session.add_all([agent, binding]) session.commit() events: list[str] = [] monkeypatch.setattr( AgentWorkspaceService, "retire_binding", MagicMock(side_effect=lambda **_kwargs: events.append("retire-binding") or "binding-1"), ) monkeypatch.setattr( AgentHomeSnapshotService, "retire_all_for_agent", MagicMock(side_effect=lambda **_kwargs: events.append("retire-home") or ["home-1"]), ) event.listen(session, "after_commit", lambda _session: events.append("commit")) monkeypatch.setattr( roster_service, "enqueue_agent_resource_collection", MagicMock(side_effect=lambda **_kwargs: events.append("enqueue")), ) service.archive_roster_agent(tenant_id="tenant-1", agent_id="agent-1", account_id="account-1") assert events == ["retire-binding", "retire-home", "commit", "enqueue"] def test_roster_archive_commit_failure_does_not_enqueue( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ) -> None: session = sqlite_session service = AgentRosterService(session) session.add(_agent()) session.commit() monkeypatch.setattr(AgentHomeSnapshotService, "retire_all_for_agent", MagicMock(return_value=["home-1"])) event.listen( session, "before_commit", lambda _session: (_ for _ in ()).throw(RuntimeError("commit failed")), ) enqueue_collection = MagicMock() monkeypatch.setattr(roster_service, "enqueue_agent_resource_collection", enqueue_collection) with pytest.raises(RuntimeError, match="commit failed"): service.archive_roster_agent(tenant_id="tenant-1", agent_id="agent-1", account_id="account-1") enqueue_collection.assert_not_called() def test_roster_create_detail_and_lookup_helpers(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): session = sqlite_session service = AgentRosterService(session) monkeypatch.setattr( AgentRosterService, "_get_or_create_agent_app_debug_conversation", lambda self, *, agent, account_id, draft_type: "debug-conversation-1", ) payload = roster_service.RosterAgentCreatePayload( name="Analyst", description="desc", role="Research assistant", icon_type="emoji", icon="A", icon_background="#fff", agent_soul=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "x"}}), version_note="initial", ) created = service.create_roster_agent(tenant_id="tenant-1", account_id="account-1", payload=payload) backing_agent = service.create_backing_agent_for_app( tenant_id="tenant-1", account_id="account-1", app_id="app-1", name="Backing Agent", role="Support agent", ) found_agent = service._get_agent(tenant_id="tenant-1", agent_id=created.id) with pytest.raises(roster_service.AgentNotFoundError): service._get_agent(tenant_id="tenant-1", agent_id="missing") found_version = service._get_version( tenant_id="tenant-1", agent_id=created.id, version_id=created.active_config_snapshot_id, ) with pytest.raises(roster_service.AgentVersionNotFoundError): service._get_version(tenant_id="tenant-1", agent_id=created.id, version_id=None) loaded_versions = service._load_versions_by_id([created.active_config_snapshot_id]) assert service._load_versions_by_id([]) == {} assert created.name == "Analyst" assert created.role == "Research assistant" assert created.source == AgentSource.ROSTER assert created.active_config_snapshot_id is not None snapshots = list( session.scalars( select(AgentConfigSnapshot) .where(AgentConfigSnapshot.agent_id.in_([created.id, backing_agent.id])) .order_by(AgentConfigSnapshot.agent_id) ) ) assert [snapshot.home_snapshot_id for snapshot in snapshots] == [None, None] assert created.active_config_has_model is False assert backing_agent.role == "Support agent" assert backing_agent.active_config_snapshot_id is not None assert backing_agent.active_config_has_model is False assert found_agent.id == created.id assert found_version.id == created.active_config_snapshot_id assert loaded_versions[created.active_config_snapshot_id].agent_id == created.id def test_get_agent_runtime_app_model_creates_hidden_backing_app_for_existing_inline_agent(sqlite_session: Session): session = sqlite_session agent = Agent( id="agent-1", tenant_id="tenant-1", app_id="workflow-app-1", workflow_id="workflow-1", workflow_node_id="node-1", name="Inline Agent", description="desc", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.WORKFLOW_ONLY, source=AgentSource.WORKFLOW, status=AgentStatus.ACTIVE, created_by="account-1", updated_by="account-1", ) session.add(agent) session.commit() service = AgentRosterService(session) resolved_app = service.get_agent_runtime_app_model(tenant_id="tenant-1", agent_id="agent-1") assert resolved_app.id == agent.backing_app_id assert resolved_app.enable_site is False assert resolved_app.enable_api is False assert session.get(App, resolved_app.id) is resolved_app def test_agent_app_build_conversation_create_reuse_and_recreate(sqlite_session: Session): session = sqlite_session agent = Agent( id="agent-1", tenant_id="tenant-1", app_id="app-1", name="Analyst", description="", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, ) session.add(agent) session.commit() service = AgentRosterService(session) created_id = service.get_or_create_build_conversation( tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", ) created_conversation = session.get(Conversation, created_id) created_mapping = session.scalar( select(AgentDebugConversation).where( AgentDebugConversation.agent_id == agent.id, AgentDebugConversation.account_id == "account-1", AgentDebugConversation.draft_type == AgentConfigDraftType.DEBUG_BUILD, ) ) assert created_conversation is not None assert created_mapping is not None assert created_id == created_mapping.conversation_id assert created_conversation.app_id == "app-1" assert created_conversation.from_account_id == "account-1" assert created_mapping.tenant_id == "tenant-1" assert created_mapping.agent_id == "agent-1" assert created_mapping.account_id == "account-1" assert created_mapping.draft_type == AgentConfigDraftType.DEBUG_BUILD reused_id = service.get_or_create_build_conversation( tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", ) assert reused_id == created_id created_conversation.is_deleted = True session.commit() recreated_id = service.get_or_create_build_conversation( tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", ) assert recreated_id == created_mapping.conversation_id assert recreated_id != created_id assert session.get(Conversation, recreated_id) is not None def test_agent_app_debug_conversations_are_isolated_by_draft_type(sqlite_session: Session): session = sqlite_session agent = Agent( id="agent-1", tenant_id="tenant-1", app_id="app-1", name="Analyst", description="", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, ) session.add(agent) session.commit() service = AgentRosterService(session) build_conversation_id = service.get_or_create_build_conversation( tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", ) preview_conversation_id = service.rotate_preview_conversation( tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", ) mappings = list(session.scalars(select(AgentDebugConversation).where(AgentDebugConversation.agent_id == agent.id))) assert build_conversation_id != preview_conversation_id assert {mapping.draft_type for mapping in mappings} == { AgentConfigDraftType.DRAFT, AgentConfigDraftType.DEBUG_BUILD, } def test_agent_app_debug_conversation_message_count(sqlite_session: Session): session = sqlite_session for index in range(3): session.add( Message( id=f"message-{index}", app_id="app-1", conversation_id="debug-conversation-1", _inputs={}, query="q", message={}, message_unit_price=0, answer="a", answer_unit_price=0, total_price=0, currency="USD", from_source=ConversationFromSource.CONSOLE, from_account_id="account-1", app_mode=AppMode.AGENT_CHAT, ) ) session.commit() count = AgentRosterService(session).count_agent_app_debug_conversation_messages( conversation_id="debug-conversation-1", ) assert count == 3 def test_agent_app_debug_conversation_requires_app_binding(sqlite_session: Session): agent = Agent( id="agent-1", tenant_id="tenant-1", app_id=None, name="Analyst", description="", scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, ) with pytest.raises(roster_service.AgentNotFoundError): AgentRosterService(sqlite_session)._get_or_create_agent_app_debug_conversation( agent=agent, account_id="account-1", draft_type=AgentConfigDraftType.DEBUG_BUILD, ) def test_load_or_create_build_conversations_supports_runtime_backed_agents(sqlite_session: Session): session = sqlite_session valid_agent = Agent( id="agent-1", tenant_id="tenant-1", app_id="app-1", name="Analyst", description="", scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, ) wrong_tenant_agent = Agent( id="agent-2", tenant_id="tenant-2", app_id="app-2", name="Other tenant", description="", scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, ) workflow_agent = Agent( id="agent-3", tenant_id="tenant-1", app_id=None, name="Workflow only", description="", scope=AgentScope.WORKFLOW_ONLY, source=AgentSource.WORKFLOW, status=AgentStatus.ACTIVE, workflow_id="workflow-1", workflow_node_id="node-1", ) session.add_all([valid_agent, wrong_tenant_agent, workflow_agent]) session.commit() result = AgentRosterService(session).load_or_create_build_conversation_ids_by_agent_id( tenant_id="tenant-1", agents=[valid_agent, wrong_tenant_agent, workflow_agent], account_id="account-1", ) assert list(result) == ["agent-1", "agent-3"] assert result["agent-1"] assert result["agent-3"] mappings = list( session.scalars(select(AgentDebugConversation).where(AgentDebugConversation.tenant_id == "tenant-1")) ) assert len(mappings) == 2 assert all(mapping.draft_type == AgentConfigDraftType.DEBUG_BUILD for mapping in mappings) def test_agent_app_visible_versions_exclude_draft_saves(): agent_app = Agent(source=AgentSource.AGENT_APP) roster_agent = Agent(source=AgentSource.ROSTER) agent_app_operations = AgentRosterService._visible_version_operations(agent_app) roster_operations = AgentRosterService._visible_version_operations(roster_agent) assert agent_app_operations == { AgentConfigRevisionOperation.PUBLISH_DRAFT, AgentConfigRevisionOperation.SAVE_NEW_VERSION, AgentConfigRevisionOperation.SAVE_TO_ROSTER, AgentConfigRevisionOperation.RESTORE_VERSION, } assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION not in agent_app_operations assert AgentConfigRevisionOperation.CREATE_VERSION in roster_operations assert AgentConfigRevisionOperation.RESTORE_VERSION in roster_operations assert AgentConfigRevisionOperation.SAVE_CURRENT_VERSION not in roster_operations def test_restore_roster_agent_version_switches_active_snapshot( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): session = sqlite_session service = AgentRosterService(session) agent = Agent( id="agent-1", tenant_id="tenant-1", name="Analyst", description="old", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, active_config_snapshot_id="version-4", active_config_is_published=True, ) version = AgentConfigSnapshot( id="version-2", tenant_id="tenant-1", agent_id="agent-1", version=2, home_snapshot_id="home-version-2", config_snapshot=_agent_soul_with_model(), ) revision = AgentConfigRevision( tenant_id="tenant-1", agent_id=agent.id, current_snapshot_id=version.id, revision=1, operation=AgentConfigRevisionOperation.PUBLISH_DRAFT, ) session.add_all([agent, version, revision]) session.commit() restored = service.restore_agent_version( tenant_id="tenant-1", agent_id="agent-1", version_id="version-2", account_id="account-1", ) assert restored == { "result": "success", "active_config_snapshot_id": "version-4", "draft_config_id": restored["draft_config_id"], "restored_version_id": "version-2", } assert agent.active_config_snapshot_id == "version-4" assert agent.active_config_is_published is False assert agent.updated_by == "account-1" draft = session.get(AgentConfigDraft, restored["draft_config_id"]) assert draft is not None assert draft.tenant_id == "tenant-1" assert draft.agent_id == "agent-1" assert draft.draft_type == AgentConfigDraftType.DRAFT assert draft.base_snapshot_id == "version-2" assert draft.home_snapshot_id == "home-version-2" assert draft.config_snapshot_dict == _agent_soul_with_model().model_dump(mode="json") assert draft.updated_by == "account-1" def test_restore_roster_agent_version_rejects_invisible_versions( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): session = sqlite_session service = AgentRosterService(session) agent = Agent( id="agent-1", tenant_id="tenant-1", name="Analyst", description="old", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, active_config_snapshot_id="version-4", ) version = AgentConfigSnapshot( id="version-2", tenant_id="tenant-1", agent_id=agent.id, version=2, config_snapshot=_agent_soul_with_model(), ) session.add_all([agent, version]) session.commit() with pytest.raises(roster_service.AgentVersionNotFoundError): service.restore_agent_version( tenant_id="tenant-1", agent_id="agent-1", version_id="version-2", account_id="account-1", ) assert agent.active_config_snapshot_id == "version-4" assert session.scalar(select(AgentConfigDraft).where(AgentConfigDraft.agent_id == agent.id)) is None def test_app_list_all_excludes_agent_apps_by_default(sqlite_session: Session): filters = AppService._build_app_list_filters("account-1", "tenant-1", AppListParams(mode="all"), sqlite_session) sql = " ".join(str(filter_) for filter_ in filters) assert "apps.mode != :mode_1" in sql def test_app_list_agent_mode_requires_visible_roster_backing_agent(sqlite_session: Session): filters = AppService._build_app_list_filters("account-1", "tenant-1", AppListParams(mode="agent"), sqlite_session) sql = " ".join(str(filter_) for filter_ in filters) assert "EXISTS" in sql assert "agents.app_id = apps.id" in sql assert "agents.scope" in sql assert "agents.source" in sql def test_validator_dict_helpers_wrap_validation_errors(): valid_soul = ComposerConfigValidator.validate_agent_soul_dict({"prompt": {"system_prompt": "x"}}) valid_node_job = ComposerConfigValidator.validate_node_job_dict({"workflow_prompt": "x"}) with pytest.raises(InvalidComposerConfigError): ComposerConfigValidator.validate_agent_soul_dict({"prompt": "not-a-dict"}) with pytest.raises(InvalidComposerConfigError): ComposerConfigValidator.validate_node_job_dict({"declared_outputs": [{"type": "string"}]}) assert valid_soul.prompt.system_prompt == "x" assert valid_node_job.workflow_prompt == "x" def test_composer_validator_rejects_stage_4_declared_output_violations(): """Stage 4 §10.1: the model-layer validators surface stage-4-specific violations through InvalidComposerConfigError so the Composer save endpoint reports them with the same error shape as other shape failures. """ # Output name violates the JSON-schema-friendly identifier pattern. with pytest.raises(InvalidComposerConfigError): ComposerConfigValidator.validate_node_job_dict({"declared_outputs": [{"name": "1bad", "type": "string"}]}) # Output check is enabled on a non-file output. with pytest.raises(InvalidComposerConfigError): ComposerConfigValidator.validate_node_job_dict( { "declared_outputs": [ { "name": "text", "type": "string", "check": { "enabled": True, "prompt": "p", "benchmark_file_ref": {"file_id": "f"}, }, } ] } ) # default_value shape doesn't match the declared type. with pytest.raises(InvalidComposerConfigError): ComposerConfigValidator.validate_node_job_dict( { "declared_outputs": [ { "name": "score", "type": "number", "failure_strategy": { "on_failure": "default_value", "default_value": "not a number", }, } ] } ) # Nested array_item is rejected outright. with pytest.raises(InvalidComposerConfigError): ComposerConfigValidator.validate_node_job_dict( {"declared_outputs": [{"name": "matrix", "type": "array", "array_item": {"type": "array"}}]} ) def test_composer_validator_rejects_invalid_shell_env_and_cli(): """ENG-367/368: env/secret names must be valid shell identifiers (no collisions), and an enabled CLI tool must declare a name or install command — caught at composer save instead of failing later in the agent backend shell layer.""" # env var name is not a valid shell identifier with pytest.raises(InvalidComposerConfigError): ComposerConfigValidator.validate_agent_soul_dict({"env": {"variables": [{"name": "bad-name"}]}}) # secret ref name is not a valid shell identifier with pytest.raises(InvalidComposerConfigError): ComposerConfigValidator.validate_agent_soul_dict({"env": {"secret_refs": [{"name": "1TOKEN"}]}}) # env var and secret ref share the shell namespace -> collision with pytest.raises(InvalidComposerConfigError): ComposerConfigValidator.validate_agent_soul_dict( { "env": { "variables": [{"name": "TOKEN", "value": "v"}], "secret_refs": [{"name": "TOKEN", "id": "credential-1"}], } } ) # CLI tool scoped env shares the same shell namespace as agent-level env. with pytest.raises(InvalidComposerConfigError): ComposerConfigValidator.validate_agent_soul_dict( { "env": {"variables": [{"name": "TOKEN", "value": "v"}]}, "tools": { "cli_tools": [ { "name": "github", "env": {"secret_refs": [{"name": "TOKEN", "credential_id": "credential-1"}]}, } ] }, } ) # CLI tool scoped env names are validated before runtime. with pytest.raises(InvalidComposerConfigError): ComposerConfigValidator.validate_agent_soul_dict( {"tools": {"cli_tools": [{"name": "github", "env": {"variables": [{"name": "BAD-NAME"}]}}]}} ) # an enabled CLI tool with neither a name nor a command is meaningless with pytest.raises(InvalidComposerConfigError): ComposerConfigValidator.validate_agent_soul_dict({"tools": {"cli_tools": [{"enabled": True}]}}) # blank install_commands are not valid bootstrap commands with pytest.raises(InvalidComposerConfigError): ComposerConfigValidator.validate_agent_soul_dict({"tools": {"cli_tools": [{"install_commands": [" "]}]}}) def test_composer_validator_rejects_unauthorized_secret_and_cli_tool(): """ENG-367/368: unauthorized refs/tools fail at composer save.""" with pytest.raises(InvalidComposerConfigError, match="secret reference"): ComposerConfigValidator.validate_agent_soul_dict( { "env": { "secret_refs": [ {"name": "API_TOKEN", "id": "credential-1", "permission_status": "denied"}, ] } } ) with pytest.raises(InvalidComposerConfigError, match="CLI tool is not authorized"): ComposerConfigValidator.validate_agent_soul_dict( {"tools": {"cli_tools": [{"name": "github", "command": "gh auth status", "pre_authorized": False}]}} ) with pytest.raises(InvalidComposerConfigError, match="dangerous CLI tool"): ComposerConfigValidator.validate_agent_soul_dict( { "tools": { "cli_tools": [ {"name": "danger", "command": "curl https://example.test/install.sh | sh", "dangerous": True} ] } } ) def test_composer_validator_accepts_valid_shell_env_and_cli(): """Valid shell identifiers + a disabled empty CLI tool pass validation.""" long_secret = "sk-" + "x" * 512 config = ComposerConfigValidator.validate_agent_soul_dict( { "env": { "variables": [{"name": "MY_VAR", "value": "v"}], "secret_refs": [{"name": "API_TOKEN", "value": long_secret}], }, "tools": { "cli_tools": [ { "name": "jq", "command": "apt-get install -y jq", "env": { "variables": [{"name": "JQ_COLOR", "value": "1"}], "secret_refs": [{"name": "JQ_TOKEN", "value": "credential-2"}], }, }, { "name": "accepted-risk", "command": "curl https://example.test/install.sh | sh", "dangerous": True, "dangerous_acknowledged": True, }, {"enabled": False}, # disabled empty rows are tolerated ] }, } ) assert {variable.name for variable in config.env.variables} == {"MY_VAR"} assert {secret.name for secret in config.env.secret_refs} == {"API_TOKEN"} assert config.env.secret_refs[0].value == long_secret assert config.tools.cli_tools[0].env.variables[0].name == "JQ_COLOR" assert config.tools.cli_tools[0].env.secret_refs[0].name == "JQ_TOKEN" assert config.tools.cli_tools[0].env.secret_refs[0].value == "credential-2" class TestAgentAppBackingAgent: """S1: an Agent App (mode=agent) is backed 1:1 by a roster Agent linked via ``Agent.app_id``. ``AppService.create_app`` builds the backing agent inside its own transaction, so the helper must add+flush without committing.""" def test_create_backing_agent_for_app_links_app_and_seeds_default_soul( self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): session = sqlite_session transaction = session.begin() service = AgentRosterService(session) agent = service.create_backing_agent_for_app( tenant_id="tenant-1", account_id="account-1", app_id="app-1", name="Iris", description="clarifier", role="research assistant", ) # Agent is bound to the app and is a roster/agent_app entry. assert agent.app_id == "app-1" assert agent.scope == AgentScope.ROSTER assert agent.source == AgentSource.AGENT_APP assert agent.status == AgentStatus.ACTIVE assert agent.agent_kind == AgentKind.DIFY_AGENT assert agent.name == "Iris" assert agent.role == "research assistant" # A v1 snapshot + revision are seeded and wired as the active version. snapshots = list(session.scalars(select(AgentConfigSnapshot).where(AgentConfigSnapshot.agent_id == agent.id))) assert len(snapshots) == 1 assert snapshots[0].version == 1 assert snapshots[0].home_snapshot_id is None assert agent.active_config_snapshot_id == snapshots[0].id revisions = list( session.scalars( select(AgentConfigRevision).where( AgentConfigRevision.agent_id == agent.id, AgentConfigRevision.operation == AgentConfigRevisionOperation.CREATE_VERSION, ) ) ) assert len(revisions) == 1 conversations = list(session.scalars(select(Conversation).where(Conversation.app_id == "app-1"))) assert len(conversations) == 1 assert conversations[0].app_id == "app-1" assert conversations[0].mode == "agent" assert conversations[0].status == ConversationStatus.NORMAL assert conversations[0].from_source == ConversationFromSource.CONSOLE assert conversations[0].from_account_id == "account-1" debug_mappings = list( session.scalars(select(AgentDebugConversation).where(AgentDebugConversation.agent_id == agent.id)) ) assert len(debug_mappings) == 1 assert debug_mappings[0].tenant_id == "tenant-1" assert debug_mappings[0].agent_id == agent.id assert debug_mappings[0].app_id == "app-1" assert debug_mappings[0].account_id == "account-1" assert debug_mappings[0].conversation_id == conversations[0].id # Caller (AppService.create_app) owns the commit — helper must not commit. assert transaction.is_active def test_get_app_backing_agent_queries_active_agent_app_agent(self, sqlite_session: Session): session = sqlite_session agent = _agent(source=AgentSource.AGENT_APP, app_id="app-1") session.add(agent) session.commit() service = AgentRosterService(session) result = service.get_app_backing_agent(tenant_id="tenant-1", app_id="app-1") assert result is agent def test_get_app_backing_agent_returns_none_when_unbound(self, sqlite_session: Session): session = sqlite_session service = AgentRosterService(session) assert service.get_app_backing_agent(tenant_id="tenant-1", app_id="app-x") is None def test_get_agent_app_model_resolves_app_backing_agent(self, sqlite_session: Session): session = sqlite_session 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, app_id="app-1", ) app = _app(mode=AppMode.AGENT) session.add_all([agent, app]) session.commit() service = AgentRosterService(session) assert service.get_agent_app_model(tenant_id="tenant-1", agent_id="agent-1") is app def test_get_agent_app_model_rejects_unbound_agent(self, sqlite_session: Session): session = sqlite_session service = AgentRosterService(session) with pytest.raises(roster_service.AgentNotFoundError): service.get_agent_app_model(tenant_id="tenant-1", agent_id="agent-x") def test_reset_build_conversation_creates_mapping(self, sqlite_session: Session): session = sqlite_session 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, app_id="app-1", ) session.add(agent) session.commit() service = AgentRosterService(session) conversation_id = service.reset_build_conversation( tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", ) conversations = list(session.scalars(select(Conversation).where(Conversation.id == conversation_id))) assert len(conversations) == 1 assert conversations[0].id == conversation_id assert conversations[0].app_id == "app-1" assert conversations[0].from_source == ConversationFromSource.CONSOLE assert conversations[0].from_account_id == "account-1" mappings = list( session.scalars(select(AgentDebugConversation).where(AgentDebugConversation.agent_id == agent.id)) ) assert len(mappings) == 1 assert mappings[0].tenant_id == "tenant-1" assert mappings[0].agent_id == "agent-1" assert mappings[0].app_id == "app-1" assert mappings[0].account_id == "account-1" assert mappings[0].draft_type == AgentConfigDraftType.DEBUG_BUILD assert mappings[0].conversation_id == conversation_id def test_rotate_preview_conversation_retires_exact_binding( self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): session = sqlite_session 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, app_id="app-1", ) mapping = AgentDebugConversation( tenant_id=agent.tenant_id, agent_id=agent.id, app_id="old-app", account_id="account-1", draft_type=AgentConfigDraftType.DRAFT, conversation_id="old-conversation", ) previous_conversation = _conversation(conversation_id="old-conversation") previous_conversation.app_id = "old-app" previous_conversation.agent_workspace_binding_id = "binding-1" binding = SimpleNamespace(agent_id=agent.id) session.add_all([agent, mapping, previous_conversation]) session.commit() service = AgentRosterService(session) events: list[str] = [] event.listen(session, "after_commit", lambda _session: events.append("commit")) get_active_binding = MagicMock(return_value=binding) retire_binding = MagicMock(side_effect=lambda **_kwargs: events.append("retire") or "binding-1") monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", get_active_binding) monkeypatch.setattr(AgentWorkspaceService, "retire_binding", retire_binding) monkeypatch.setattr( roster_service, "enqueue_agent_resource_collection", MagicMock(side_effect=lambda **_kwargs: events.append("enqueue")), ) conversation_id = service.rotate_preview_conversation( tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", ) assert mapping.app_id == "app-1" assert mapping.conversation_id == conversation_id conversations = list(session.scalars(select(Conversation).where(Conversation.id == conversation_id))) assert len(conversations) == 1 assert conversations[0].id == conversation_id assert events == ["retire", "commit", "enqueue"] owner_scope = get_active_binding.call_args.kwargs["expected_owner_scope"] assert owner_scope.owner_type is AgentWorkspaceOwnerType.CONVERSATION assert owner_scope.owner_id == previous_conversation.id retire_binding.assert_called_once_with( session=session, tenant_id="tenant-1", binding_id="binding-1", ) def test_reset_build_conversation_retires_build_draft_binding( self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): session = sqlite_session 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, app_id="app-1", ) mapping = AgentDebugConversation( tenant_id=agent.tenant_id, agent_id=agent.id, app_id="app-1", account_id="account-1", draft_type=AgentConfigDraftType.DEBUG_BUILD, conversation_id="old-build-conversation", ) build_draft = AgentConfigDraft( id="build-draft-1", tenant_id=agent.tenant_id, agent_id=agent.id, draft_type=AgentConfigDraftType.DEBUG_BUILD, account_id="account-1", draft_owner_key="account-1", agent_workspace_binding_id="binding-1", config_snapshot=AgentSoulConfig(), ) session.add_all([agent, mapping, build_draft]) session.commit() get_active_binding = MagicMock(return_value=SimpleNamespace(agent_id=agent.id)) retire_binding = MagicMock(return_value="binding-1") enqueue_collection = MagicMock() monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", get_active_binding) monkeypatch.setattr(AgentWorkspaceService, "retire_binding", retire_binding) monkeypatch.setattr(roster_service, "enqueue_agent_resource_collection", enqueue_collection) conversation_id = AgentRosterService(session).reset_build_conversation( tenant_id="tenant-1", agent_id=agent.id, account_id="account-1", ) assert mapping.conversation_id == conversation_id assert build_draft.agent_workspace_binding_id is None owner_scope = get_active_binding.call_args.kwargs["expected_owner_scope"] assert owner_scope.owner_type is AgentWorkspaceOwnerType.BUILD_DRAFT assert owner_scope.owner_id == build_draft.id retire_binding.assert_called_once_with( session=session, tenant_id="tenant-1", binding_id="binding-1", ) enqueue_collection.assert_called_once_with( tenant_id="tenant-1", binding_ids=("binding-1",), ) def test_preview_rotation_commit_failure_rolls_back_before_enqueue( self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): session = sqlite_session 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, app_id="app-1", ) mapping = AgentDebugConversation( tenant_id=agent.tenant_id, agent_id=agent.id, app_id="app-1", account_id="account-1", draft_type=AgentConfigDraftType.DRAFT, conversation_id="old-conversation", ) previous_conversation = _conversation(conversation_id="old-conversation") previous_conversation.agent_workspace_binding_id = "binding-1" session.add_all([agent, mapping, previous_conversation]) session.commit() event.listen( session, "before_commit", lambda _session: (_ for _ in ()).throw(RuntimeError("commit failed")), ) monkeypatch.setattr( AgentWorkspaceService, "get_active_binding", MagicMock(return_value=SimpleNamespace(agent_id=agent.id)), ) monkeypatch.setattr(AgentWorkspaceService, "retire_binding", MagicMock(return_value="binding-1")) enqueue_collection = MagicMock() monkeypatch.setattr(roster_service, "enqueue_agent_resource_collection", enqueue_collection) with pytest.raises(RuntimeError, match="commit failed"): AgentRosterService(session).rotate_preview_conversation( tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", ) assert session.get(AgentDebugConversation, mapping.id).conversation_id == "old-conversation" enqueue_collection.assert_not_called() def test_build_reset_commit_failure_rolls_back_before_enqueue( self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): session = sqlite_session 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, app_id="app-1", ) mapping = AgentDebugConversation( tenant_id=agent.tenant_id, agent_id=agent.id, app_id="app-1", account_id="account-1", draft_type=AgentConfigDraftType.DEBUG_BUILD, conversation_id="old-build-conversation", ) build_draft = AgentConfigDraft( id="build-draft-1", tenant_id=agent.tenant_id, agent_id=agent.id, draft_type=AgentConfigDraftType.DEBUG_BUILD, account_id="account-1", draft_owner_key="account-1", agent_workspace_binding_id="binding-1", config_snapshot=AgentSoulConfig(), ) session.add_all([agent, mapping, build_draft]) session.commit() events: list[str] = [] def fail_commit(_session: Session) -> None: assert build_draft.agent_workspace_binding_id is None events.append("commit") raise RuntimeError("commit failed") def rollback(_session: Session) -> None: events.append("rollback") event.listen(session, "before_commit", fail_commit) event.listen(session, "after_rollback", rollback) get_active_binding = MagicMock(return_value=SimpleNamespace(agent_id=agent.id)) monkeypatch.setattr(AgentWorkspaceService, "get_active_binding", get_active_binding) retire_binding = MagicMock(side_effect=lambda **_kwargs: events.append("retire") or "binding-1") monkeypatch.setattr(AgentWorkspaceService, "retire_binding", retire_binding) enqueue_collection = MagicMock() monkeypatch.setattr(roster_service, "enqueue_agent_resource_collection", enqueue_collection) with pytest.raises(RuntimeError, match="commit failed"): AgentRosterService(session).reset_build_conversation( tenant_id="tenant-1", agent_id=agent.id, account_id="account-1", ) assert events == ["retire", "commit", "rollback"] owner_scope = get_active_binding.call_args.kwargs["expected_owner_scope"] assert owner_scope.owner_type is AgentWorkspaceOwnerType.BUILD_DRAFT assert owner_scope.owner_id == build_draft.id retire_binding.assert_called_once_with( session=session, tenant_id="tenant-1", binding_id="binding-1", ) enqueue_collection.assert_not_called() def test_duplicate_agent_app_copies_app_config_and_active_soul( self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): source_config = SimpleNamespace( opening_statement="hello", suggested_questions='["q1"]', suggested_questions_after_answer='{"enabled": true}', speech_to_text='{"enabled": false}', text_to_speech='{"enabled": false}', more_like_this='{"enabled": false}', model=None, user_input_form=None, dataset_query_variable=None, pre_prompt=None, agent_mode=None, sensitive_word_avoidance=None, retriever_resource='{"enabled": true}', prompt_type="simple", chat_prompt_config=None, completion_prompt_config=None, dataset_configs=None, external_data_tools=None, file_upload='{"image": {"enabled": true}}', ) target_config = SimpleNamespace(**dict.fromkeys(AgentRosterService._APP_MODEL_CONFIG_COPY_FIELDS)) source_app = SimpleNamespace( id="source-app", tenant_id="tenant-1", name="Iris", description="source desc", icon_type="emoji", icon="robot", icon_background="#fff", api_rph=1, api_rpm=2, max_active_requests=3, enable_site=False, enable_api=True, use_icon_as_answer_icon=True, tracing="{}", app_model_config=source_config, app_model_config_with_session=lambda *, session: source_config, ) target_app = SimpleNamespace( id="target-app", app_model_config=target_config, app_model_config_with_session=lambda *, session: target_config, enable_site=True, enable_api=True, use_icon_as_answer_icon=False, tracing=None, ) source_agent = Agent( id="source-agent", tenant_id="tenant-1", name="Iris", description="source desc", role="Analyst", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, app_id="source-app", active_config_snapshot_id="source-version", active_config_has_model=True, active_config_is_published=True, ) source_version = AgentConfigSnapshot( id="source-version", tenant_id="tenant-1", agent_id="source-agent", version=1, home_snapshot_id="home-source", config_snapshot=_agent_soul_with_model(), summary="configured", version_note="v1", created_by="account-1", ) session = sqlite_session service = AgentRosterService(session) captured: dict[str, object] = {} class FakeAppService: def create_app(self, tenant_id: str, params, account: object, *, session) -> object: captured["tenant_id"] = tenant_id captured["params"] = params captured["account"] = account target_agent = AgentRosterService(session).create_backing_agent_for_app( tenant_id=tenant_id, account_id="account-1", app_id=target_app.id, name=params.name, description=params.description, role=params.agent_role, ) target_version = session.scalar( select(AgentConfigSnapshot).where(AgentConfigSnapshot.agent_id == target_agent.id) ) assert target_version is not None captured["target_agent"] = target_agent captured["target_version"] = target_version return target_app monkeypatch.setattr(roster_service, "AppService", FakeAppService) monkeypatch.setattr( AgentRosterService, "_get_or_create_agent_app_debug_conversation", lambda _self, **_kwargs: None, ) monkeypatch.setattr(service, "get_agent_app_model", lambda **_kwargs: source_app) monkeypatch.setattr( service, "get_app_backing_agent", lambda *, tenant_id, app_id: ( source_agent if app_id == source_app.id else session.scalar(select(Agent).where(Agent.tenant_id == tenant_id, Agent.app_id == app_id)) ), ) monkeypatch.setattr( service, "_get_version", lambda *, version_id, **_kwargs: ( source_version if version_id == source_version.id else session.get(AgentConfigSnapshot, version_id) ), ) monkeypatch.setattr(service, "_next_duplicate_agent_name", lambda **_kwargs: "Iris copy") monkeypatch.setattr( roster_service.FeatureService, "get_system_features", lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=False)), ) account = SimpleNamespace(id="account-1") duplicated = service.duplicate_agent_app( tenant_id="tenant-1", agent_id="source-agent", account=account, ) assert duplicated is target_app params = captured["params"] assert params.name == "Iris copy" assert params.mode == "agent" assert params.agent_role == "Analyst" assert target_app.enable_site is False assert target_app.enable_api is True assert target_app.use_icon_as_answer_icon is True assert target_app.tracing == "{}" assert target_config.opening_statement == "hello" assert target_config.file_upload == '{"image": {"enabled": true}}' assert target_config.updated_by == "account-1" target_agent = captured["target_agent"] target_version = captured["target_version"] assert target_version.config_snapshot.model.model == "gpt-4o" assert source_version.home_snapshot_id == "home-source" assert target_version.home_snapshot_id is None assert target_version.summary == "configured" assert target_version.version_note == "v1" assert target_agent.active_config_has_model is True assert target_agent.updated_by == "account-1" assert session.get(Agent, target_agent.id) is target_agent def test_duplicate_agent_app_inherits_webapp_access_mode( self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): source_app = SimpleNamespace( id="source-app", tenant_id="tenant-1", name="Iris", description="source desc", icon_type=None, icon="robot", icon_background="#fff", api_rph=1, api_rpm=2, max_active_requests=3, enable_site=True, enable_api=True, use_icon_as_answer_icon=False, tracing=None, ) source_agent = SimpleNamespace(id="source-agent", role="Analyst") target_app = SimpleNamespace(id="target-app") session = sqlite_session service = AgentRosterService(session) monkeypatch.setattr(service, "get_agent_app_model", lambda **_: source_app) monkeypatch.setattr(service, "get_app_backing_agent", lambda **_: source_agent) monkeypatch.setattr(service, "_copy_app_model_config", lambda **_: None) monkeypatch.setattr(service, "_copy_agent_active_snapshot", lambda **_: None) monkeypatch.setattr(service, "_next_duplicate_agent_name", lambda **_: "Iris copy") captured: dict[str, object] = {} class FakeAppService: def create_app(self, tenant_id: str, params, account: object, *, session) -> object: captured["params"] = params return target_app access_mode_updates = [] class FakeWebAppAuth: @classmethod def get_app_access_mode_by_id(cls, app_id: str) -> object: return SimpleNamespace(access_mode="private") @classmethod def update_app_access_mode(cls, app_id: str, access_mode: str) -> None: access_mode_updates.append((app_id, access_mode)) monkeypatch.setattr(roster_service, "AppService", FakeAppService) monkeypatch.setattr( roster_service.FeatureService, "get_system_features", lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True)), ) monkeypatch.setattr(roster_service.EnterpriseService, "WebAppAuth", FakeWebAppAuth) duplicated = service.duplicate_agent_app( tenant_id="tenant-1", agent_id="source-agent", account=SimpleNamespace(id="account-1"), role="Custom Analyst", ) assert duplicated is target_app assert captured["params"].agent_role == "Custom Analyst" assert access_mode_updates == [("target-app", "private")] def test_duplicate_agent_app_falls_back_to_public_access_mode( self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): source_app = SimpleNamespace( id="source-app", tenant_id="tenant-1", name="Iris", description="source desc", icon_type=IconType.EMOJI, icon="robot", icon_background="#fff", api_rph=1, api_rpm=2, max_active_requests=3, enable_site=True, enable_api=True, use_icon_as_answer_icon=False, tracing=None, ) source_agent = SimpleNamespace(id="source-agent", role="Analyst") target_app = SimpleNamespace(id="target-app") session = sqlite_session service = AgentRosterService(session) monkeypatch.setattr(service, "get_agent_app_model", lambda **_: source_app) monkeypatch.setattr(service, "get_app_backing_agent", lambda **_: source_agent) monkeypatch.setattr(service, "_copy_app_model_config", lambda **_: None) monkeypatch.setattr(service, "_copy_agent_active_snapshot", lambda **_: None) monkeypatch.setattr(service, "_next_duplicate_agent_name", lambda **_: "Iris copy") class FakeAppService: def create_app(self, tenant_id: str, params, account: object, *, session) -> object: return target_app access_mode_updates = [] class FakeWebAppAuth: @classmethod def get_app_access_mode_by_id(cls, app_id: str) -> object: raise ValueError("not found") @classmethod def update_app_access_mode(cls, app_id: str, access_mode: str) -> None: access_mode_updates.append((app_id, access_mode)) monkeypatch.setattr(roster_service, "AppService", FakeAppService) monkeypatch.setattr( roster_service.FeatureService, "get_system_features", lambda: SimpleNamespace(webapp_auth=SimpleNamespace(enabled=True)), ) monkeypatch.setattr(roster_service.EnterpriseService, "WebAppAuth", FakeWebAppAuth) service.duplicate_agent_app( tenant_id="tenant-1", agent_id="source-agent", account=SimpleNamespace(id="account-1"), ) assert access_mode_updates == [("target-app", "public")] def test_normalize_app_icon_type(self): assert AgentRosterService._normalize_app_icon_type(None) is None assert AgentRosterService._normalize_app_icon_type(IconType.EMOJI) == "emoji" assert AgentRosterService._normalize_app_icon_type("image") == "image" class TestListWorkflowsReferencingAppAgent: def test_groups_bindings_by_workflow_app_and_sorts_by_name(self, sqlite_session: Session): session = sqlite_session agent = _agent(source=AgentSource.AGENT_APP, app_id="app-1") bindings = [ WorkflowAgentNodeBinding( tenant_id="tenant-1", agent_id="agent-1", app_id="wf-app-1", workflow_id="wf-1", workflow_version="v1", node_id="node-b", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, current_snapshot_id="version-1", node_job_config=WorkflowNodeJobConfig(), ), WorkflowAgentNodeBinding( tenant_id="tenant-1", agent_id="agent-1", app_id="wf-app-1", workflow_id="wf-1", workflow_version="v1", node_id="node-a", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, current_snapshot_id="version-1", node_job_config=WorkflowNodeJobConfig(), ), WorkflowAgentNodeBinding( tenant_id="tenant-1", agent_id="agent-1", app_id="wf-app-2", workflow_id="wf-2", workflow_version="v2", node_id="node-a", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, current_snapshot_id="version-1", node_job_config=WorkflowNodeJobConfig(), ), ] beta_app = _app(app_id="wf-app-1", name="Beta Flow", mode=AppMode.WORKFLOW) beta_app.workflow_id = "wf-1" alpha_app = _app(app_id="wf-app-2", name="Alpha Flow", mode=AppMode.ADVANCED_CHAT) alpha_app.workflow_id = "wf-2" session.add_all([agent, *bindings, beta_app, alpha_app]) session.commit() service = AgentRosterService(session) result = service.list_workflows_referencing_app_agent(tenant_id="tenant-1", app_id="app-1") assert [r["app_name"] for r in result] == ["Alpha Flow", "Beta Flow"] beta = next(r for r in result if r["app_id"] == "wf-app-1") assert beta["node_ids"] == ["node-a", "node-b"] # deduped + sorted assert beta["workflow_id"] == "wf-1" assert beta["workflow_version"] == "v1" def test_returns_empty_when_no_backing_agent(self, sqlite_session: Session): session = sqlite_session # scalar() -> None service = AgentRosterService(session) assert service.list_workflows_referencing_app_agent(tenant_id="tenant-1", app_id="app-x") == [] def test_returns_empty_when_no_bindings(self, sqlite_session: Session): session = sqlite_session session.add(_agent(source=AgentSource.AGENT_APP, app_id="app-1")) session.commit() service = AgentRosterService(session) assert service.list_workflows_referencing_app_agent(tenant_id="tenant-1", app_id="app-1") == [] def test_skips_orphaned_binding_whose_app_is_gone(self, sqlite_session: Session): session = sqlite_session agent = _agent(source=AgentSource.AGENT_APP, app_id="app-1") binding = WorkflowAgentNodeBinding( tenant_id="tenant-1", agent_id=agent.id, app_id="wf-app-gone", workflow_id="wf-9", workflow_version="v9", node_id="node-a", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, current_snapshot_id="version-1", node_job_config=WorkflowNodeJobConfig(), ) session.add_all([agent, binding]) session.commit() service = AgentRosterService(session) assert service.list_workflows_referencing_app_agent(tenant_id="tenant-1", app_id="app-1") == [] def test_skips_historical_published_workflow_versions(self, sqlite_session: Session): session = sqlite_session agent = _agent(source=AgentSource.AGENT_APP, app_id="app-1") bindings = [ WorkflowAgentNodeBinding( tenant_id="tenant-1", agent_id=agent.id, app_id="wf-app-1", workflow_id="old-wf", workflow_version="old", node_id="old", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, current_snapshot_id="version-1", node_job_config=WorkflowNodeJobConfig(), ), WorkflowAgentNodeBinding( tenant_id="tenant-1", agent_id=agent.id, app_id="wf-app-1", workflow_id="current-wf", workflow_version="v2", node_id="new", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, current_snapshot_id="version-1", node_job_config=WorkflowNodeJobConfig(), ), ] app = _app(app_id="wf-app-1", name="Flow", mode=AppMode.WORKFLOW) app.workflow_id = "current-wf" session.add_all([agent, *bindings, app]) session.commit() service = AgentRosterService(session) result = service.list_workflows_referencing_app_agent(tenant_id="tenant-1", app_id="app-1") assert len(result) == 1 assert result[0]["workflow_id"] == "current-wf" assert result[0]["node_ids"] == ["new"] class TestWorkflowAgentDraftBindingSync: def _agent_workflow(self) -> Workflow: workflow = _workflow() workflow.graph = json.dumps( { "nodes": [{"id": "agent-node", "data": {"type": "agent", "version": "2"}}], "edges": [], } ) return workflow def _agent_binding(self) -> WorkflowAgentNodeBinding: return WorkflowAgentNodeBinding( id="binding-1", tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version=Workflow.VERSION_DRAFT, node_id="agent-node", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, agent_id="agent-1", current_snapshot_id="snapshot-1", node_job_config=WorkflowNodeJobConfig(), ) def _publish_agent(self) -> Agent: return Agent( id="agent-1", tenant_id="tenant-1", name="Iris", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.ROSTER, status=AgentStatus.ACTIVE, active_config_snapshot_id="snapshot-1", ) def _snapshot(self, agent_soul: AgentSoulConfig) -> AgentConfigSnapshot: return AgentConfigSnapshot( id="snapshot-1", tenant_id="tenant-1", agent_id="agent-1", version=1, config_snapshot=agent_soul, ) def _publish_revision(self, snapshot_id: str = "snapshot-2") -> AgentConfigRevision: return AgentConfigRevision( id="revision-1", tenant_id="tenant-1", agent_id="agent-1", current_snapshot_id=snapshot_id, revision=1, operation=AgentConfigRevisionOperation.PUBLISH_DRAFT, created_by="account-1", ) def _sync_roster_agent_task_refs( self, *, agent_task: str, existing_ref_selectors: list[list[str]] | None = None, sqlite_session: Session, ) -> WorkflowNodeJobConfig: workflow = _workflow() workflow.graph = json.dumps( { "nodes": [ { "id": "agent-node", "data": { "type": "agent", "version": "2", "agent_task": agent_task, "agent_binding": { "binding_type": "roster_agent", "agent_id": "agent-1", }, }, } ] } ) agent = Agent( id="agent-1", tenant_id="tenant-1", name="Agent", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, active_config_snapshot_id="snapshot-2", ) session = sqlite_session existing_binding = None if existing_ref_selectors is not None: existing_binding = WorkflowAgentNodeBinding( id="binding-1", tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version=Workflow.VERSION_DRAFT, node_id="agent-node", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, agent_id="agent-1", current_snapshot_id="snapshot-1", node_job_config=WorkflowNodeJobConfig.model_validate( { "workflow_prompt": "Old prompt", "previous_node_output_refs": [{"selector": selector} for selector in existing_ref_selectors], } ), ) session.add_all([agent, self._publish_revision()]) if existing_binding is not None: session.add(existing_binding) session.commit() WorkflowAgentPublishService.sync_roster_agent_bindings_for_draft( session=session, draft_workflow=workflow, account_id="account-1", ) binding = existing_binding or session.scalar( select(WorkflowAgentNodeBinding).where(WorkflowAgentNodeBinding.node_id == "agent-node") ) assert binding is not None return WorkflowNodeJobConfig.model_validate(binding.node_job_config_dict) def test_publish_validation_rejects_agent_soul_publish_only_errors(self, sqlite_session: Session): session = sqlite_session binding = self._agent_binding() agent_soul = AgentSoulConfig.model_validate( { "model": { "plugin_id": "langgenius/openai/openai", "model_provider": "openai", "model": "gpt-4o", }, "prompt": {"system_prompt": "no human reference yet"}, "human": {"contacts": [{"id": "human-1", "name": "Reviewer"}]}, } ) agent = self._publish_agent() snapshot = self._snapshot(agent_soul) session.add_all([binding, agent, snapshot]) session.commit() with pytest.raises(InvalidComposerConfigError, match="human_involvement_not_referenced"): WorkflowAgentPublishService.validate_agent_nodes_for_publish( session=session, draft_workflow=self._agent_workflow(), ) def test_publish_validation_rejects_dangling_agent_soul_drive_refs(self, sqlite_session: Session): session = sqlite_session binding = self._agent_binding() agent_soul = AgentSoulConfig.model_validate( { "model": { "plugin_id": "langgenius/openai/openai", "model_provider": "openai", "model": "gpt-4o", }, "prompt": {"system_prompt": "Use [§skill:research%2FSKILL.md:Research§]."}, } ) agent = self._publish_agent() snapshot = self._snapshot(agent_soul) session.add_all([binding, agent, snapshot]) session.commit() with pytest.raises(WorkflowAgentNodeValidationError, match="skill_ref_dangling"): WorkflowAgentPublishService.validate_agent_nodes_for_publish( session=session, draft_workflow=self._agent_workflow(), ) def test_publish_validation_rejects_missing_config_assets(self): payload = ComposerSavePayload.model_validate( { "variant": "agent_app", "save_strategy": "save_as_new_version", "agent_soul": { "config_skills": [{"name": "research", "file_id": "", "is_missing": True}], "config_files": [ { "name": "guide.txt", "file_kind": "upload_file", "file_id": "", "is_missing": True, } ], }, } ) with pytest.raises(InvalidComposerConfigError, match="config_asset_missing.*skill:research.*file:guide.txt"): ComposerConfigValidator.validate_publish_payload(payload) def test_projects_binding_declared_outputs_to_draft_graph_response(self, sqlite_session: Session): session = sqlite_session workflow = Workflow( id="workflow-1", tenant_id="tenant-1", app_id="app-1", version=Workflow.VERSION_DRAFT, graph=json.dumps( { "nodes": [ { "id": "agent-node", "data": { "type": "agent", "version": "2", "agent_binding": { "binding_type": "roster_agent", "agent_id": "agent-1", }, }, } ], "edges": [], } ), ) binding = WorkflowAgentNodeBinding( id="binding-1", tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version=Workflow.VERSION_DRAFT, node_id="agent-node", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, agent_id="agent-1", current_snapshot_id="snapshot-1", node_job_config=WorkflowNodeJobConfig( workflow_prompt="Summarize the upstream result.", declared_outputs=[ DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING, description="Short summary"), DeclaredOutputConfig( name="profile", type=DeclaredOutputType.OBJECT, children=[ DeclaredOutputChildConfig(name="email", type=DeclaredOutputType.STRING), DeclaredOutputChildConfig( name="addresses", type=DeclaredOutputType.ARRAY, array_item=DeclaredArrayItem( type=DeclaredOutputType.OBJECT, children=[DeclaredOutputChildConfig(name="city", type=DeclaredOutputType.STRING)], ), ), ], ), ], ), ) session.add(binding) session.commit() graph = WorkflowAgentPublishService.project_draft_bindings_to_graph( session=session, draft_workflow=workflow, ) node_data = graph["nodes"][0]["data"] assert node_data["agent_binding"] == { "binding_type": "roster_agent", "agent_id": "agent-1", "current_snapshot_id": "snapshot-1", } assert node_data["agent_task"] == "Summarize the upstream result." assert node_data["agent_declared_outputs"][0]["name"] == "summary" assert node_data["agent_declared_outputs"][0]["type"] == "string" assert node_data["agent_declared_outputs"][0]["description"] == "Short summary" profile_output = node_data["agent_declared_outputs"][1] assert profile_output["children"][0]["name"] == "email" assert profile_output["children"][1]["array_item"]["children"][0]["name"] == "city" assert "agent_declared_outputs" not in workflow.graph_dict["nodes"][0]["data"] def test_projects_inline_binding_over_pending_inline_graph_response(self, sqlite_session: Session): session = sqlite_session workflow = Workflow( id="workflow-1", tenant_id="tenant-1", app_id="app-1", version=Workflow.VERSION_DRAFT, graph=json.dumps( { "nodes": [ { "id": "agent-node", "data": { "type": "agent", "version": "2", "agent_binding": { "binding_type": "inline_agent", }, }, } ], "edges": [], } ), ) binding = WorkflowAgentNodeBinding( id="binding-1", tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version=Workflow.VERSION_DRAFT, node_id="agent-node", binding_type=WorkflowAgentBindingType.INLINE_AGENT, agent_id="inline-agent-1", current_snapshot_id="inline-snapshot-1", node_job_config=WorkflowNodeJobConfig(), ) session.add(binding) session.commit() graph = WorkflowAgentPublishService.project_draft_bindings_to_graph( session=session, draft_workflow=workflow, ) assert graph["nodes"][0]["data"]["agent_binding"] == { "binding_type": "inline_agent", "agent_id": "inline-agent-1", "current_snapshot_id": "inline-snapshot-1", } assert workflow.graph_dict["nodes"][0]["data"]["agent_binding"] == { "binding_type": "inline_agent", } def test_keeps_pending_inline_graph_response_over_existing_roster_binding(self, sqlite_session: Session): session = sqlite_session workflow = Workflow( id="workflow-1", tenant_id="tenant-1", app_id="app-1", version=Workflow.VERSION_DRAFT, graph=json.dumps( { "nodes": [ { "id": "agent-node", "data": { "type": "agent", "version": "2", "agent_binding": { "binding_type": "inline_agent", }, }, } ], "edges": [], } ), ) binding = WorkflowAgentNodeBinding( id="binding-1", tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version=Workflow.VERSION_DRAFT, node_id="agent-node", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, agent_id="agent-1", current_snapshot_id="snapshot-1", node_job_config=WorkflowNodeJobConfig(), ) session.add(binding) session.commit() graph = WorkflowAgentPublishService.project_draft_bindings_to_graph( session=session, draft_workflow=workflow, ) assert graph["nodes"][0]["data"]["agent_binding"] == { "binding_type": "inline_agent", } def test_creates_roster_binding_from_agent_node_graph(self, sqlite_session: Session): session = sqlite_session workflow = Workflow( id="workflow-1", tenant_id="tenant-1", app_id="app-1", version=Workflow.VERSION_DRAFT, graph=json.dumps( { "nodes": [ { "id": "agent-node", "data": { "type": "agent", "version": "2", "agent_task": "Summarize the upstream result.", "agent_declared_outputs": [ { "name": "summary", "type": "string", "description": "Short summary", } ], "agent_binding": { "binding_type": "roster_agent", "agent_id": "agent-1", }, }, } ] } ), ) agent = Agent( id="agent-1", tenant_id="tenant-1", name="Agent", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, active_config_snapshot_id="snapshot-2", ) session.add_all([agent, self._publish_revision()]) session.commit() WorkflowAgentPublishService.sync_roster_agent_bindings_for_draft( session=session, draft_workflow=workflow, account_id="account-1", ) binding = session.scalar( select(WorkflowAgentNodeBinding).where(WorkflowAgentNodeBinding.node_id == "agent-node") ) assert binding is not None assert binding.binding_type == WorkflowAgentBindingType.ROSTER_AGENT assert binding.agent_id == "agent-1" assert binding.current_snapshot_id == "snapshot-2" assert binding.node_job_config_dict == WorkflowNodeJobConfig( workflow_prompt="Summarize the upstream result.", declared_outputs=[ DeclaredOutputConfig( name="summary", type=DeclaredOutputType.STRING, description="Short summary", ) ], ).model_dump(mode="json") def test_creates_roster_binding_deriving_previous_node_refs_from_agent_task(self, sqlite_session: Session): node_job = self._sync_roster_agent_task_refs( agent_task="Review {{#previous-node.report#}} for {{#sys.query#}}.", sqlite_session=sqlite_session, ) assert node_job.workflow_prompt == "Review {{#previous-node.report#}} for {{#sys.query#}}." assert [ref.selector for ref in node_job.previous_node_output_refs] == [["previous-node", "report"]] def test_updates_existing_roster_binding_clearing_legacy_only_previous_node_refs(self, sqlite_session: Session): node_job = self._sync_roster_agent_task_refs( agent_task="Review [§node_output:previous-node.report:PREV/report§].", existing_ref_selectors=[["previous-node", "report"]], sqlite_session=sqlite_session, ) assert node_job.workflow_prompt == "Review [§node_output:previous-node.report:PREV/report§]." assert node_job.previous_node_output_refs == [] def test_updates_existing_roster_binding_clearing_stale_previous_node_refs(self, sqlite_session: Session): node_job = self._sync_roster_agent_task_refs( agent_task="Review the current request without upstream context.", existing_ref_selectors=[["previous-node", "report"]], sqlite_session=sqlite_session, ) assert node_job.workflow_prompt == "Review the current request without upstream context." assert node_job.previous_node_output_refs == [] def test_creates_inline_binding_from_agent_node_graph(self, sqlite_session: Session): session = sqlite_session workflow = Workflow( id="workflow-1", tenant_id="tenant-1", app_id="app-1", version=Workflow.VERSION_DRAFT, graph=json.dumps( { "nodes": [ { "id": "agent-node", "data": { "type": "agent", "version": "2", "agent_task": "Use the current node context.", "agent_binding": { "binding_type": "inline_agent", "agent_id": "inline-agent-1", "current_snapshot_id": "inline-snapshot-1", }, }, } ] } ), ) agent = Agent( id="inline-agent-1", tenant_id="tenant-1", name="Workflow Agent agent-node", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.WORKFLOW_ONLY, source=AgentSource.WORKFLOW, app_id="app-1", workflow_id="workflow-1", workflow_node_id="agent-node", status=AgentStatus.ACTIVE, active_config_snapshot_id="inline-snapshot-1", ) snapshot = AgentConfigSnapshot( id="inline-snapshot-1", tenant_id="tenant-1", agent_id="inline-agent-1", version=1, config_snapshot=AgentSoulConfig(), ) session.add_all([agent, snapshot]) session.commit() WorkflowAgentPublishService.sync_agent_bindings_for_draft( session=session, draft_workflow=workflow, account_id="account-1", ) binding = session.scalar( select(WorkflowAgentNodeBinding).where(WorkflowAgentNodeBinding.node_id == "agent-node") ) assert binding is not None assert binding.binding_type == WorkflowAgentBindingType.INLINE_AGENT assert binding.agent_id == "inline-agent-1" assert binding.current_snapshot_id == "inline-snapshot-1" assert binding.node_job_config_dict == WorkflowNodeJobConfig( workflow_prompt="Use the current node context.", ).model_dump(mode="json") def test_keeps_pending_inline_binding_in_draft_graph_without_db_binding(self, sqlite_session: Session): session = sqlite_session workflow = Workflow( id="workflow-1", tenant_id="tenant-1", app_id="app-1", version=Workflow.VERSION_DRAFT, graph=json.dumps( { "nodes": [ { "id": "agent-node", "data": { "type": "agent", "version": "2", "agent_binding": { "binding_type": "inline_agent", }, }, } ] } ), ) existing_binding = WorkflowAgentNodeBinding( id="binding-1", tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version=Workflow.VERSION_DRAFT, node_id="agent-node", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, agent_id="agent-1", current_snapshot_id="snapshot-1", node_job_config=WorkflowNodeJobConfig(), ) session.add(existing_binding) session.commit() WorkflowAgentPublishService.sync_agent_bindings_for_draft( session=session, draft_workflow=workflow, account_id="account-1", ) assert session.get(WorkflowAgentNodeBinding, existing_binding.id) is existing_binding assert not session.new def test_clones_inline_binding_for_agent_owned_by_another_node(self, monkeypatch, sqlite_session: Session): session = sqlite_session workflow = Workflow( id="workflow-1", tenant_id="tenant-1", app_id="app-1", version=Workflow.VERSION_DRAFT, graph=json.dumps( { "nodes": [ { "id": "agent-node", "data": { "type": "agent", "version": "2", "agent_binding": { "binding_type": "inline_agent", "agent_id": "inline-agent-1", "current_snapshot_id": "inline-snapshot-1", }, }, } ] } ), ) agent = Agent( id="inline-agent-1", tenant_id="tenant-1", name="Workflow Agent other-node", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.WORKFLOW_ONLY, source=AgentSource.WORKFLOW, app_id="app-1", workflow_id="workflow-1", workflow_node_id="other-node", status=AgentStatus.ACTIVE, active_config_snapshot_id="inline-snapshot-1", ) session.add(agent) session.commit() clone = MagicMock(return_value=(SimpleNamespace(id="cloned-agent"), "cloned-snapshot")) monkeypatch.setattr(WorkflowAgentPublishService, "_clone_inline_graph_binding_for_node", clone) WorkflowAgentPublishService.sync_agent_bindings_for_draft( session=session, draft_workflow=workflow, account_id="account-1", ) clone.assert_called_once() binding = session.scalar( select(WorkflowAgentNodeBinding).where(WorkflowAgentNodeBinding.node_id == "agent-node") ) assert binding is not None assert binding.agent_id == "cloned-agent" assert binding.current_snapshot_id == "cloned-snapshot" def test_rejects_agent_node_graph_binding_with_unsupported_type(self, sqlite_session: Session): session = sqlite_session workflow = Workflow( id="workflow-1", tenant_id="tenant-1", app_id="app-1", version=Workflow.VERSION_DRAFT, graph=json.dumps( { "nodes": [ { "id": "agent-node", "data": { "type": "agent", "version": "2", "agent_binding": { "binding_type": "unknown", "agent_id": "agent-1", }, }, } ] } ), ) with pytest.raises(ValueError, match="unsupported agent_binding type"): WorkflowAgentPublishService.sync_agent_bindings_for_draft( session=session, draft_workflow=workflow, account_id="account-1", ) def test_treats_partial_inline_binding_as_pending_draft_state(self, sqlite_session: Session): session = sqlite_session workflow = Workflow( id="workflow-1", tenant_id="tenant-1", app_id="app-1", version=Workflow.VERSION_DRAFT, graph=json.dumps( { "nodes": [ { "id": "agent-node", "data": { "type": "agent", "version": "2", "agent_binding": { "binding_type": "inline_agent", "agent_id": "inline-agent-1", }, }, } ] } ), ) WorkflowAgentPublishService.sync_agent_bindings_for_draft( session=session, draft_workflow=workflow, account_id="account-1", ) assert session.scalar(select(WorkflowAgentNodeBinding)) is None def test_rejects_inline_binding_with_missing_snapshot(self, sqlite_session: Session): session = sqlite_session workflow = Workflow( id="workflow-1", tenant_id="tenant-1", app_id="app-1", version=Workflow.VERSION_DRAFT, graph=json.dumps( { "nodes": [ { "id": "agent-node", "data": { "type": "agent", "version": "2", "agent_binding": { "binding_type": "inline_agent", "agent_id": "inline-agent-1", "current_snapshot_id": "missing-snapshot", }, }, } ] } ), ) agent = Agent( id="inline-agent-1", tenant_id="tenant-1", name="Workflow Agent agent-node", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.WORKFLOW_ONLY, source=AgentSource.WORKFLOW, app_id="app-1", workflow_id="workflow-1", workflow_node_id="agent-node", status=AgentStatus.ACTIVE, active_config_snapshot_id="inline-snapshot-1", ) session.add(agent) session.commit() with pytest.raises(ValueError, match="missing inline agent config snapshot"): WorkflowAgentPublishService.sync_agent_bindings_for_draft( session=session, draft_workflow=workflow, account_id="account-1", ) def test_updates_existing_roster_binding_prompt_from_agent_node_graph(self, sqlite_session: Session): session = sqlite_session workflow = Workflow( id="workflow-1", tenant_id="tenant-1", app_id="app-1", version=Workflow.VERSION_DRAFT, graph=json.dumps( { "nodes": [ { "id": "agent-node", "data": { "type": "agent", "version": "2", "agent_task": "Use the latest tender context.", "agent_binding": { "binding_type": "roster_agent", "agent_id": "agent-1", }, }, } ] } ), ) agent = Agent( id="agent-1", tenant_id="tenant-1", name="Agent", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, active_config_snapshot_id="snapshot-2", ) existing_binding = WorkflowAgentNodeBinding( id="binding-1", tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version=Workflow.VERSION_DRAFT, node_id="agent-node", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, agent_id="agent-1", current_snapshot_id="snapshot-1", node_job_config=WorkflowNodeJobConfig( workflow_prompt="Old prompt", declared_outputs=[ DeclaredOutputConfig(name="summary", type=DeclaredOutputType.STRING, description="Short summary") ], ), ) session.add_all([agent, self._publish_revision(), existing_binding]) session.commit() WorkflowAgentPublishService.sync_roster_agent_bindings_for_draft( session=session, draft_workflow=workflow, account_id="account-1", ) node_job = WorkflowNodeJobConfig.model_validate(existing_binding.node_job_config_dict) assert node_job.workflow_prompt == "Use the latest tender context." assert [output.name for output in node_job.declared_outputs] == ["summary"] assert existing_binding.current_snapshot_id == "snapshot-2" def test_updates_existing_roster_binding_declared_outputs_from_agent_node_graph(self, sqlite_session: Session): session = sqlite_session workflow = Workflow( id="workflow-1", tenant_id="tenant-1", app_id="app-1", version=Workflow.VERSION_DRAFT, graph=json.dumps( { "nodes": [ { "id": "agent-node", "data": { "type": "agent", "version": "2", "agent_task": "Keep the prompt.", "agent_declared_outputs": [], "agent_binding": { "binding_type": "roster_agent", "agent_id": "agent-1", }, }, } ] } ), ) agent = Agent( id="agent-1", tenant_id="tenant-1", name="Agent", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, active_config_snapshot_id="snapshot-2", ) existing_binding = WorkflowAgentNodeBinding( id="binding-1", tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version=Workflow.VERSION_DRAFT, node_id="agent-node", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, agent_id="agent-1", current_snapshot_id="snapshot-1", node_job_config=WorkflowNodeJobConfig( workflow_prompt="Old prompt", declared_outputs=[ DeclaredOutputConfig( name="summary", type=DeclaredOutputType.STRING, description="Short summary", ) ], ), ) session.add_all([agent, self._publish_revision(), existing_binding]) session.commit() WorkflowAgentPublishService.sync_roster_agent_bindings_for_draft( session=session, draft_workflow=workflow, account_id="account-1", ) node_job = WorkflowNodeJobConfig.model_validate(existing_binding.node_job_config_dict) assert node_job.workflow_prompt == "Keep the prompt." assert node_job.declared_outputs == [] assert existing_binding.current_snapshot_id == "snapshot-2" @pytest.mark.parametrize( "sqlite_session", [(Agent, AgentConfigRevision, AgentConfigSnapshot, WorkflowAgentNodeBinding)], indirect=True, ) def test_deletes_draft_binding_and_returns_only_replaced_inline_agent( self, monkeypatch: pytest.MonkeyPatch, sqlite_session: Session, ) -> None: workflow = Workflow( id="workflow-1", tenant_id="tenant-1", app_id="app-1", version=Workflow.VERSION_DRAFT, graph=json.dumps( { "nodes": [ { "id": "kept-node", "data": { "type": "agent", "version": "2", "agent_binding": { "binding_type": "inline_agent", "agent_id": "inline-kept", "current_snapshot_id": "snapshot-kept", }, }, }, { "id": "roster-node", "data": { "type": "agent", "version": "2", "agent_binding": { "binding_type": "roster_agent", "agent_id": "roster-new", }, }, }, { "id": "inline-replaced-node", "data": { "type": "agent", "version": "2", "agent_binding": { "binding_type": "inline_agent", "agent_id": "inline-new", "current_snapshot_id": "snapshot-inline-new", }, }, }, ] } ), ) removed_inline = WorkflowAgentNodeBinding( id="binding-inline-removed", tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version=Workflow.VERSION_DRAFT, node_id="removed-node", binding_type=WorkflowAgentBindingType.INLINE_AGENT, agent_id="inline-removed", current_snapshot_id="snapshot-removed", node_job_config=WorkflowNodeJobConfig(), ) kept_inline = WorkflowAgentNodeBinding( id="binding-inline-kept", tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version=Workflow.VERSION_DRAFT, node_id="kept-node", binding_type=WorkflowAgentBindingType.INLINE_AGENT, agent_id="inline-kept", current_snapshot_id="snapshot-kept", node_job_config=WorkflowNodeJobConfig(), ) removed_roster = WorkflowAgentNodeBinding( id="binding-roster-removed", tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version=Workflow.VERSION_DRAFT, node_id="removed-roster-node", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, agent_id="roster-removed", current_snapshot_id="snapshot-roster", node_job_config=WorkflowNodeJobConfig(), ) old_inline_replaced_by_roster = WorkflowAgentNodeBinding( id="binding-inline-to-roster", tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version=Workflow.VERSION_DRAFT, node_id="roster-node", binding_type=WorkflowAgentBindingType.INLINE_AGENT, agent_id="inline-old-roster", current_snapshot_id="snapshot-inline-old-roster", node_job_config=WorkflowNodeJobConfig(), ) old_inline_replaced_by_inline = WorkflowAgentNodeBinding( id="binding-inline-to-inline", tenant_id="tenant-1", app_id="app-1", workflow_id="workflow-1", workflow_version=Workflow.VERSION_DRAFT, node_id="inline-replaced-node", binding_type=WorkflowAgentBindingType.INLINE_AGENT, agent_id="inline-old-inline", current_snapshot_id="snapshot-inline-old-inline", node_job_config=WorkflowNodeJobConfig(), ) kept_agent = Agent( id="inline-kept", tenant_id="tenant-1", name="Kept inline", description="", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.WORKFLOW_ONLY, source=AgentSource.WORKFLOW, status=AgentStatus.ACTIVE, app_id="app-1", workflow_id="workflow-1", workflow_node_id="kept-node", active_config_snapshot_id="snapshot-kept", ) replacement_agent = Agent( id="inline-new", tenant_id="tenant-1", name="Replacement inline", description="", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.WORKFLOW_ONLY, source=AgentSource.WORKFLOW, status=AgentStatus.ACTIVE, app_id="app-1", workflow_id="workflow-1", workflow_node_id="inline-replaced-node", active_config_snapshot_id="snapshot-inline-new", ) roster_agent = Agent( id="roster-new", tenant_id="tenant-1", name="Roster replacement", description="", agent_kind=AgentKind.DIFY_AGENT, scope=AgentScope.ROSTER, source=AgentSource.ROSTER, status=AgentStatus.ACTIVE, active_config_snapshot_id="snapshot-roster-new", ) kept_snapshot = AgentConfigSnapshot( id="snapshot-kept", tenant_id="tenant-1", agent_id="inline-kept", version=1, config_snapshot=AgentSoulConfig(), home_snapshot_id="home-kept", ) replacement_snapshot = AgentConfigSnapshot( id="snapshot-inline-new", tenant_id="tenant-1", agent_id="inline-new", version=1, config_snapshot=AgentSoulConfig(), home_snapshot_id="home-inline-new", ) sqlite_session.add_all( [ kept_agent, replacement_agent, roster_agent, kept_snapshot, replacement_snapshot, removed_inline, kept_inline, removed_roster, old_inline_replaced_by_roster, old_inline_replaced_by_inline, ] ) sqlite_session.commit() retirement_candidates = WorkflowAgentPublishService.sync_agent_bindings_for_draft( session=sqlite_session, draft_workflow=workflow, account_id="account-1", ) assert sqlite_session.get(WorkflowAgentNodeBinding, removed_inline.id) is None assert sqlite_session.get(WorkflowAgentNodeBinding, removed_roster.id) is None assert kept_inline.binding_type == WorkflowAgentBindingType.INLINE_AGENT assert kept_inline.agent_id == "inline-kept" assert old_inline_replaced_by_roster.binding_type == WorkflowAgentBindingType.ROSTER_AGENT assert old_inline_replaced_by_roster.agent_id == "roster-new" assert old_inline_replaced_by_roster.current_snapshot_id == "snapshot-roster-new" assert old_inline_replaced_by_inline.binding_type == WorkflowAgentBindingType.INLINE_AGENT assert old_inline_replaced_by_inline.agent_id == "inline-new" assert old_inline_replaced_by_inline.current_snapshot_id == "snapshot-inline-new" assert retirement_candidates == {"inline-removed", "inline-old-roster", "inline-old-inline"} def test_dataset_rows_filters_malformed_ids(monkeypatch: pytest.MonkeyPatch, sqlite_session: Session): """Mention ids are user-editable text: a non-UUID id must read as missing (placeholder semantics), never reach the UUID-typed dataset query (E2E 500).""" captured = {} def fake_get_datasets_by_ids(ids, tenant_id, *, session): captured["ids"] = ids return [], 0 import services.dataset_service as dataset_service_module from services.agent.knowledge_datasets import get_tenant_knowledge_dataset_rows monkeypatch.setattr(dataset_service_module.DatasetService, "get_datasets_by_ids", fake_get_datasets_by_ids) valid = "550e8400-e29b-41d4-a716-446655440000" rows = get_tenant_knowledge_dataset_rows( session=sqlite_session, tenant_id="tenant-1", dataset_ids=["9999dead-beef", valid] ) assert rows == {} assert captured["ids"] == [valid] # all-malformed input never touches the DB captured.clear() assert get_tenant_knowledge_dataset_rows(session=sqlite_session, tenant_id="tenant-1", dataset_ids=["nope"]) == {} assert captured == {} def test_composer_save_rejects_malformed_knowledge_dataset_ids( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): captured = {"calls": 0} def fake_get_datasets_by_ids(ids, tenant_id, *, session): captured["calls"] += 1 captured["ids"] = ids captured["tenant_id"] = tenant_id return [], 0 import services.dataset_service as dataset_service_module 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": "not-a-uuid"}], "query": {"mode": "generated_query"}, "retrieval": {"mode": "multiple", "top_k": 4}, } ] } } ) with pytest.raises(InvalidComposerConfigError, match="not-a-uuid"): AgentComposerService.validate_knowledge_datasets( session=sqlite_session, tenant_id="tenant-1", agent_soul=agent_soul ) assert captured == {"calls": 0} def test_composer_save_rejects_missing_or_out_of_scope_knowledge_datasets( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): captured = {} missing_dataset_id = "550e8400-e29b-41d4-a716-446655440000" def fake_get_datasets_by_ids(ids, tenant_id, *, session): captured["ids"] = ids captured["tenant_id"] = tenant_id return [], 0 import services.dataset_service as dataset_service_module 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( session=sqlite_session, 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, sqlite_session: Session ): session = sqlite_session agent = _agent( source=AgentSource.AGENT_APP, app_id="app-1", ) agent.active_config_snapshot_id = "version-1" agent.active_config_is_published = True revision = AgentConfigRevision( id="revision-1", tenant_id="tenant-1", agent_id=agent.id, current_snapshot_id="version-1", revision=1, operation=AgentConfigRevisionOperation.PUBLISH_DRAFT, created_by="account-1", ) session.add_all([agent, revision]) session.commit() active_version = SimpleNamespace( home_snapshot_id="home-initial", config_snapshot_dict=AgentSoulConfig().model_dump(mode="json") ) saved = {} flushes = 0 def count_flush(_session: Session, _flush_context: object) -> None: nonlocal flushes flushes += 1 event.listen(session, "after_flush", count_flush) import services.dataset_service as dataset_service_module 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", home_snapshot_id="home-initial"), ) 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": ComposerVariant.AGENT_APP.value, "save_strategy": ComposerSaveStrategy.SAVE_TO_CURRENT_VERSION.value, "agent_soul": { "knowledge": { "sets": [ { "id": "support", "name": "Support KB", "datasets": [{"id": "not-a-uuid"}], "query": {"mode": "generated_query"}, "retrieval": {"mode": "single"}, "metadata_filtering": {"mode": "automatic"}, } ] } }, } ) result = AgentComposerService.save_agent_composer( session=session, tenant_id="tenant-1", agent_id="agent-1", account_id="account-1", payload=payload, ) 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 flushes >= 1 assert not session.dirty def test_workspace_dify_tools_returns_provider_and_tool_granularities(monkeypatch: pytest.MonkeyPatch): """The slash-menu Tools tab needs both selection granularities: a provider hosts many tools (like an MCP server), so candidates return one provider-level entry (id = /*, = all tools) plus one per tool.""" from types import SimpleNamespace provider = SimpleNamespace( name="duckduckgo", plugin_id="langgenius/duckduckgo", label=SimpleNamespace(en_US="DuckDuckGo"), description=SimpleNamespace(en_US="Privacy-first web search"), tools=[ SimpleNamespace(name="ddg_search", label=SimpleNamespace(en_US="DuckDuckGo Search")), SimpleNamespace(name="ddg_news", label=SimpleNamespace(en_US="DuckDuckGo News")), ], ) import services.tools.builtin_tools_manage_service as builtin_tools_module monkeypatch.setattr( builtin_tools_module.BuiltinToolManageService, "list_builtin_tools", staticmethod(lambda user_id, tenant_id: [provider]), ) entries = AgentComposerService._workspace_dify_tools(tenant_id="tenant-1", user_id="user-1") assert entries[0] == { "id": "duckduckgo/*", "granularity": "provider", "name": "DuckDuckGo", "description": "Privacy-first web search", "provider": "duckduckgo", "plugin_id": "langgenius/duckduckgo", "tools_count": 2, } assert [entry["id"] for entry in entries[1:]] == ["duckduckgo/ddg_search", "duckduckgo/ddg_news"] assert {entry["granularity"] for entry in entries[1:]} == {"tool"} # ── ENG-623 §4.4: drive-backed prompt mention validation ───────────────────── def _drive_soul(**overrides): from services.entities.agent_entities import AgentSoulConfig base = { "prompt": { "system_prompt": ( "Use [§skill:tender-analyzer%2FSKILL.md:Tender Analyzer§] and [§file:files%2Fsample.pdf:sample.pdf§]." ) }, } base.update(overrides) return AgentSoulConfig.model_validate(base) def _session_with_drive_keys(sqlite_session: Session, existing_keys: list[str]) -> Session: session = sqlite_session session.add_all( [ AgentDriveFile( id=f"drive-file-{index}", tenant_id="tenant-1", agent_id="agent-1", key=key, file_kind=AgentDriveFileKind.UPLOAD_FILE, file_id=f"upload-{index}", ) for index, key in enumerate(existing_keys, start=1) ] ) session.commit() return session def test_drive_mention_findings_reports_missing_keys(sqlite_session: Session): session = _session_with_drive_keys(sqlite_session, ["tender-analyzer/SKILL.md"]) findings = AgentComposerService._drive_mention_findings( session=session, tenant_id="tenant-1", agent_id="agent-1", prompt=_drive_soul().prompt.system_prompt, ) assert [(f["code"], f["id"]) for f in findings] == [("mention_target_missing", "files/sample.pdf")] assert findings[0]["kind"] == "file" assert str(findings[0]["message"]).startswith("file 'sample.pdf' has no drive entry") def test_drive_mention_findings_clean_when_all_keys_exist(sqlite_session: Session): session = _session_with_drive_keys( sqlite_session, ["tender-analyzer/SKILL.md", "files/sample.pdf"], ) assert ( AgentComposerService._drive_mention_findings( session=session, tenant_id="tenant-1", agent_id="agent-1", prompt=_drive_soul().prompt.system_prompt, ) == [] ) def test_drive_mention_findings_skips_prompt_without_drive_mentions(sqlite_session: Session): session = sqlite_session # No drive-backed mention at all -> no DB roundtrip, no findings. soul = _drive_soul(prompt={"system_prompt": "Use [§knowledge:kb-1:Docs§]."}) findings = AgentComposerService._drive_mention_findings( session=session, tenant_id="tenant-1", agent_id="agent-1", prompt=soul.prompt.system_prompt, ) assert findings == [] def test_collect_validation_findings_appends_drive_mention_findings_with_agent_context( sqlite_session: Session, ): from services.entities.agent_entities import ComposerSavePayload session = _session_with_drive_keys(sqlite_session, []) payload = ComposerSavePayload.model_validate( { "variant": "agent_app", "save_strategy": "save_to_current_version", "agent_soul": _drive_soul().model_dump(mode="json"), } ) findings = AgentComposerService.collect_validation_findings( session=session, tenant_id="tenant-1", payload=payload, agent_id="agent-1" ) codes = {w["code"] for w in findings["warnings"]} assert codes >= {"mention_target_missing"} assert {w["id"] for w in findings["warnings"] if w["code"] == "mention_target_missing"} == { "tender-analyzer/SKILL.md", "files/sample.pdf", } # without agent context the drive check is skipped entirely findings_no_agent = AgentComposerService.collect_validation_findings( session=session, tenant_id="tenant-1", payload=payload ) assert all(w["code"] != "mention_target_missing" for w in findings_no_agent["warnings"]) # ── ENG-623/625: resolver helpers + save-path drive guard ──────────────────── def test_resolve_bound_agent_id_queries_active_roster_agent(sqlite_session: Session): session = sqlite_session session.add( _agent( agent_id="agent-9", tenant_id="t-1", source=AgentSource.ROSTER, app_id="app-1", ) ) session.commit() assert AgentComposerService.resolve_bound_agent_id(session=session, tenant_id="t-1", app_id="app-1") == "agent-9" def test_resolve_workflow_node_agent_id_degrades_without_workflow_or_binding( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): session = sqlite_session from types import SimpleNamespace def boom(cls, **kwargs): raise ValueError("no draft workflow") monkeypatch.setattr(AgentComposerService, "_get_draft_workflow", classmethod(boom)) assert ( AgentComposerService.resolve_workflow_node_agent_id(session=session, tenant_id="t", app_id="a", node_id="n") is None ) monkeypatch.setattr( AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1")) ) monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: None)) assert ( AgentComposerService.resolve_workflow_node_agent_id(session=session, tenant_id="t", app_id="a", node_id="n") is None ) monkeypatch.setattr( AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: SimpleNamespace(agent_id="agent-7")), ) assert ( AgentComposerService.resolve_workflow_node_agent_id(session=session, tenant_id="t", app_id="a", node_id="n") == "agent-7" ) def test_save_workflow_composer_reports_drive_mentions_for_inline_node_job_only( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): payload = ComposerSavePayload.model_validate( { "variant": "workflow", "save_strategy": "node_job_only", "agent_soul": _drive_soul().model_dump(mode="json"), "soul_lock": {"locked": False}, } ) binding = WorkflowAgentNodeBinding( tenant_id="t-1", app_id="app-1", workflow_id="wf-1", workflow_version="draft", node_id="n-1", binding_type=WorkflowAgentBindingType.INLINE_AGENT, agent_id="agent-1", current_snapshot_id="version-1", ) session = sqlite_session monkeypatch.setattr( AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1")) ) monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding)) monkeypatch.setattr(AgentComposerService, "_save_node_job_only", classmethod(lambda cls, **kwargs: binding)) monkeypatch.setattr( AgentComposerService, "_get_agent_if_present", classmethod(lambda cls, **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1")), ) monkeypatch.setattr( AgentComposerService, "_get_version_if_present", classmethod(lambda cls, **kwargs: SimpleNamespace(id="version-1")), ) monkeypatch.setattr( AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"}) ) guarded: dict[str, str] = {} def fake_collect(cls, *, session, tenant_id, payload, agent_id=None): guarded["tenant_id"] = tenant_id guarded["agent_id"] = agent_id return {"warnings": [{"code": "mention_target_missing", "id": "files/sample.pdf"}]} monkeypatch.setattr(AgentComposerService, "collect_validation_findings", classmethod(fake_collect)) result = AgentComposerService.save_workflow_composer( session=session, tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload, ) assert result == { "state": "ok", "validation": {"warnings": [{"code": "mention_target_missing", "id": "files/sample.pdf"}]}, } assert guarded == {"tenant_id": "t-1", "agent_id": "agent-1"} def test_save_workflow_composer_reports_drive_mentions_for_roster_node_job_only( monkeypatch: pytest.MonkeyPatch, sqlite_session: Session ): payload = ComposerSavePayload.model_validate( { "variant": "workflow", "save_strategy": "node_job_only", "agent_soul": _drive_soul().model_dump(mode="json"), "soul_lock": {"locked": False}, } ) binding = WorkflowAgentNodeBinding( tenant_id="t-1", app_id="app-1", workflow_id="wf-1", workflow_version="draft", node_id="n-1", binding_type=WorkflowAgentBindingType.ROSTER_AGENT, agent_id="agent-1", current_snapshot_id="version-1", ) session = sqlite_session monkeypatch.setattr( AgentComposerService, "_get_draft_workflow", classmethod(lambda cls, **kwargs: SimpleNamespace(id="wf-1")) ) monkeypatch.setattr(AgentComposerService, "_get_workflow_binding", classmethod(lambda cls, **kwargs: binding)) monkeypatch.setattr(AgentComposerService, "_save_node_job_only", classmethod(lambda cls, **kwargs: binding)) monkeypatch.setattr( AgentComposerService, "_get_agent_if_present", classmethod(lambda cls, **kwargs: SimpleNamespace(id="agent-1", active_config_snapshot_id="version-1")), ) monkeypatch.setattr( AgentComposerService, "_get_version_if_present", classmethod(lambda cls, **kwargs: SimpleNamespace(id="version-1")), ) monkeypatch.setattr( AgentComposerService, "_serialize_workflow_state", classmethod(lambda cls, **kwargs: {"state": "ok"}) ) captured: dict[str, str | None] = {} def fake_collect(cls, *, session, tenant_id, payload, agent_id=None): captured["agent_id"] = agent_id return {"warnings": []} monkeypatch.setattr(AgentComposerService, "collect_validation_findings", classmethod(fake_collect)) result = AgentComposerService.save_workflow_composer( session=session, tenant_id="t-1", app_id="app-1", node_id="n-1", account_id="acc-1", payload=payload, ) assert result == {"state": "ok", "validation": {"warnings": []}} assert captured["agent_id"] == "agent-1"