From 4da4fa72cd9d011559fad3746547c935941724b2 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Wed, 22 Jul 2026 20:54:09 +0800 Subject: [PATCH] fix(agent): refresh stale inline preview drafts (#39415) --- api/core/app/apps/agent_app/app_generator.py | 43 +++---- api/services/agent/composer_service.py | 75 ++++++++++++ .../app/apps/agent_app/test_resolve_agent.py | 79 ++++++++++++- .../services/agent/test_agent_services.py | 110 ++++++++++++++++++ 4 files changed, 276 insertions(+), 31 deletions(-) diff --git a/api/core/app/apps/agent_app/app_generator.py b/api/core/app/apps/agent_app/app_generator.py index 73a663f92b2..26e5d0cdcb6 100644 --- a/api/core/app/apps/agent_app/app_generator.py +++ b/api/core/app/apps/agent_app/app_generator.py @@ -682,42 +682,27 @@ class AgentAppGenerator(MessageBasedAppGenerator): if draft_type == AgentConfigDraftType.DEBUG_BUILD.value else AgentConfigDraftType.DRAFT ) + if effective_draft_type == AgentConfigDraftType.DRAFT: + from services.agent.composer_service import AgentComposerService + + return AgentComposerService.get_or_create_normal_agent_draft( + session=session, + tenant_id=tenant_id, + agent=agent, + created_by=agent.updated_by or agent.created_by, + ) + if not account_id: + raise AgentAppGeneratorError("Build draft requires an account user") stmt = select(AgentConfigDraft).where( AgentConfigDraft.tenant_id == tenant_id, AgentConfigDraft.agent_id == agent.id, - AgentConfigDraft.draft_type == effective_draft_type, + AgentConfigDraft.draft_type == AgentConfigDraftType.DEBUG_BUILD, + AgentConfigDraft.account_id == account_id, ) - if effective_draft_type == AgentConfigDraftType.DEBUG_BUILD: - if not account_id: - raise AgentAppGeneratorError("Build draft requires an account user") - stmt = stmt.where(AgentConfigDraft.account_id == account_id) - else: - stmt = stmt.where(AgentConfigDraft.account_id.is_(None)) draft = session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1)) if draft is not None: return draft - if effective_draft_type == AgentConfigDraftType.DEBUG_BUILD: - raise AgentAppGeneratorError("Agent build draft not found") - _, snapshot, agent_soul = AgentAppGenerator._resolve_agent_by_id( - tenant_id=tenant_id, - agent_id=agent.id, - snapshot_id=agent.active_config_snapshot_id, - session=session, - ) - draft = AgentConfigDraft( - tenant_id=tenant_id, - agent_id=agent.id, - draft_type=AgentConfigDraftType.DRAFT, - account_id=None, - draft_owner_key="", - base_snapshot_id=snapshot.id, - config_snapshot=agent_soul, - created_by=agent.created_by, - updated_by=agent.updated_by, - ) - session.add(draft) - session.flush() - return draft + raise AgentAppGeneratorError("Agent build draft not found") @staticmethod def _resolve_agent_by_id( diff --git a/api/services/agent/composer_service.py b/api/services/agent/composer_service.py index b9cd1db148c..48bb6d54a4f 100644 --- a/api/services/agent/composer_service.py +++ b/api/services/agent/composer_service.py @@ -1164,6 +1164,20 @@ class AgentComposerService: agent.active_config_is_published = True agent.updated_by = account_id binding.current_snapshot_id = version.id + normal_draft = cls._get_agent_draft( + session=session, + tenant_id=tenant_id, + agent_id=agent.id, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + ) + if normal_draft is not None and cls._rebase_workflow_only_normal_draft( + agent=agent, + draft=normal_draft, + snapshot=version, + updated_by=account_id, + ): + session.flush() binding.updated_by = account_id return binding @@ -1748,6 +1762,47 @@ class AgentComposerService: stmt = stmt.where(AgentConfigDraft.account_id.is_(None)) return session.scalar(stmt.order_by(AgentConfigDraft.updated_at.desc()).limit(1)) + @classmethod + def get_or_create_normal_agent_draft( + cls, + *, + session: Session, + tenant_id: str, + agent: Agent, + created_by: str | None, + ) -> AgentConfigDraft: + """Resolve the shared Preview draft, rebasing inline agents when needed.""" + return cls._get_or_create_agent_draft( + session=session, + tenant_id=tenant_id, + agent=agent, + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + created_by=created_by, + ) + + @staticmethod + def _rebase_workflow_only_normal_draft( + *, + agent: Agent, + draft: AgentConfigDraft, + snapshot: AgentConfigSnapshot, + updated_by: str | None, + ) -> bool: + if ( + agent.scope != AgentScope.WORKFLOW_ONLY + or draft.draft_type != AgentConfigDraftType.DRAFT + or draft.account_id is not None + or not agent.active_config_snapshot_id + or draft.base_snapshot_id == agent.active_config_snapshot_id + or snapshot.id != agent.active_config_snapshot_id + ): + return False + draft.base_snapshot_id = snapshot.id + draft.config_snapshot = AgentSoulConfig.model_validate(snapshot.config_snapshot_dict) + draft.updated_by = updated_by + return True + @classmethod def _get_or_create_agent_draft( cls, @@ -1767,6 +1822,26 @@ class AgentComposerService: account_id=account_id, ) if draft is not None: + if ( + agent.scope == AgentScope.WORKFLOW_ONLY + and draft_type == AgentConfigDraftType.DRAFT + and draft.account_id is None + and agent.active_config_snapshot_id + and draft.base_snapshot_id != agent.active_config_snapshot_id + ): + active_snapshot = cls._get_version_if_present( + session=session, + tenant_id=tenant_id, + agent_id=agent.id, + version_id=agent.active_config_snapshot_id, + ) + if active_snapshot is not None and cls._rebase_workflow_only_normal_draft( + agent=agent, + draft=draft, + snapshot=active_snapshot, + updated_by=agent.updated_by or agent.created_by, + ): + session.flush() return draft base_snapshot = cls._get_version_if_present( session=session, diff --git a/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py b/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py index 3fd3eee1b87..f65770665e4 100644 --- a/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py +++ b/api/tests/unit_tests/core/app/apps/agent_app/test_resolve_agent.py @@ -14,7 +14,8 @@ import pytest from core.app.apps.agent_app.app_generator import AgentAppGenerator, AgentAppGeneratorError, AgentAppNotPublishedError from core.app.entities.app_invoke_entities import InvokeFrom -from models.agent import AgentConfigDraftType, AgentSource +from models.agent import AgentConfigDraft, AgentConfigDraftType, AgentScope, AgentSource +from models.agent_config_entities import AgentSoulConfig _SOUL_DICT = { "model": { @@ -95,7 +96,7 @@ class TestResolveDebugDraft: created_by="creator-1", updated_by="updater-1", ) - session = _FakeScalarSession([None, SimpleNamespace(id="agent-1"), _snapshot()]) + session = _FakeScalarSession([None, _snapshot()]) draft = AgentAppGenerator._resolve_debug_draft( tenant_id="t1", @@ -110,6 +111,77 @@ class TestResolveDebugDraft: assert session.added == [draft] assert session.flush_count == 1 + def test_stale_workflow_only_shared_draft_is_rebased_to_active_snapshot(self): + agent = SimpleNamespace( + id="agent-1", + scope=AgentScope.WORKFLOW_ONLY, + active_config_snapshot_id="snap-2", + created_by="creator-1", + updated_by="updater-1", + ) + draft = AgentConfigDraft( + id="draft-1", + tenant_id="t1", + agent_id="agent-1", + draft_type=AgentConfigDraftType.DRAFT, + account_id=None, + draft_owner_key="", + base_snapshot_id="snap-1", + config_snapshot=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "old"}}), + ) + active_snapshot = SimpleNamespace( + id="snap-2", + config_snapshot_dict={"prompt": {"system_prompt": "new"}}, + ) + session = _FakeScalarSession([draft, active_snapshot]) + + resolved = AgentAppGenerator._resolve_debug_draft( + tenant_id="t1", + agent=agent, + draft_type=None, + account_id=None, + session=session, + ) + + assert resolved is draft + assert resolved.id == "draft-1" + assert resolved.base_snapshot_id == "snap-2" + assert resolved.config_snapshot_dict["prompt"]["system_prompt"] == "new" + assert session.flush_count == 1 + + def test_build_draft_is_not_rebased_to_active_snapshot(self): + agent = SimpleNamespace( + id="agent-1", + scope=AgentScope.WORKFLOW_ONLY, + active_config_snapshot_id="snap-2", + created_by="creator-1", + updated_by="updater-1", + ) + draft = AgentConfigDraft( + id="build-draft-1", + tenant_id="t1", + agent_id="agent-1", + draft_type=AgentConfigDraftType.DEBUG_BUILD, + account_id="account-1", + draft_owner_key="account-1", + base_snapshot_id="snap-1", + config_snapshot=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "build edit"}}), + ) + session = _FakeScalarSession([draft]) + + resolved = AgentAppGenerator._resolve_debug_draft( + tenant_id="t1", + agent=agent, + draft_type=AgentConfigDraftType.DEBUG_BUILD.value, + account_id="account-1", + session=session, + ) + + assert resolved is draft + assert resolved.base_snapshot_id == "snap-1" + assert resolved.config_snapshot_dict["prompt"]["system_prompt"] == "build edit" + assert session.flush_count == 0 + class TestResolveAgent: def test_success_chains_to_resolve_by_id(self): @@ -185,9 +257,12 @@ class TestResolveAgent: def test_unpublished_imported_agent_remains_available_to_debugger(self): bound_agent = SimpleNamespace( id="agent-1", + scope=AgentScope.ROSTER, source=AgentSource.IMPORTED, active_config_snapshot_id="snap-1", active_config_is_published=False, + created_by="creator-1", + updated_by="updater-1", ) draft = SimpleNamespace(id="draft-1", draft_type="draft", config_snapshot_dict=_SOUL_DICT) session = _FakeScalarSession([bound_agent, draft]) diff --git a/api/tests/unit_tests/services/agent/test_agent_services.py b/api/tests/unit_tests/services/agent/test_agent_services.py index a70eb496da6..06f42b47542 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -1271,11 +1271,32 @@ def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch tenant_id="tenant-1", agent_id="inline-agent-1", version=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", + 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", @@ -1320,6 +1341,95 @@ def test_node_job_only_updates_inline_agent_soul(monkeypatch: pytest.MonkeyPatch 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.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(): + 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", + 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, + config_snapshot=AgentSoulConfig.model_validate({"prompt": {"system_prompt": "new"}}), + ) + session = FakeSession(scalar=[draft, active_snapshot]) + + 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.config_snapshot_dict == active_snapshot.config_snapshot_dict + assert resolved.updated_by == "account-2" + assert session.flushes == 1 + + +def test_get_or_create_normal_agent_draft_keeps_roster_draft_edits(): + 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 = FakeSession(scalar=[draft]) + + 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" + assert session.flushes == 0 def test_node_job_only_switches_roster_binding_to_inline_agent(monkeypatch: pytest.MonkeyPatch):