diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index c06a5710cdd..6b0db7a0b48 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -62,7 +62,7 @@ from libs.datetime_utils import parse_time_range from libs.helper import dump_response from libs.login import login_required from models import Account -from models.agent import Agent, AgentStatus +from models.agent import Agent, AgentConfigDraftType, AgentStatus from models.agent_config_entities import AgentSoulConfig from models.enums import ApiTokenType from models.model import ApiToken, App, IconType @@ -266,6 +266,13 @@ class AgentDebugConversationRefreshResponse(BaseModel): debug_conversation_message_count: int = 0 +class AgentDebugConversationRefreshPayload(BaseModel): + draft_type: AgentConfigDraftType = Field( + default=AgentConfigDraftType.DEBUG_BUILD, + description="Agent draft surface whose conversation should be refreshed", + ) + + class AgentPublishPayload(BaseModel): version_note: str | None = Field(default=None, description="Optional note for this published Agent version") @@ -309,6 +316,7 @@ register_schema_models( AgentAppCopyPayload, AgentPublishPayload, AgentBuildDraftCheckoutPayload, + AgentDebugConversationRefreshPayload, ComposerSavePayload, AgentApiStatusPayload, AgentInviteOptionsQuery, @@ -392,6 +400,7 @@ def _serialize_agent_app_detail( tenant_id=app_model.tenant_id, agent_id=agent.id, account_id=current_user.id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, commit=False, ) message_count = roster_service.count_agent_app_debug_conversation_messages( @@ -439,6 +448,7 @@ def _serialize_agent_app_pagination(session: Session, app_pagination, *, tenant_ tenant_id=tenant_id, agents=list(agents_by_app_id.values()), account_id=current_user.id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, ) payload = AgentAppPagination.model_validate( app_pagination, @@ -655,6 +665,16 @@ class AgentAppApi(Resource): @console_ns.route("/agent//debug-conversation/refresh") class AgentDebugConversationRefreshApi(Resource): + @console_ns.expect(console_ns.models[AgentDebugConversationRefreshPayload.__name__]) + @console_ns.doc( + params={ + "payload": { + "in": "body", + "required": False, + "schema": {"$ref": f"#/components/schemas/{AgentDebugConversationRefreshPayload.__name__}"}, + } + } + ) @console_ns.response( 200, "Agent debug conversation refreshed", @@ -669,10 +689,12 @@ class AgentDebugConversationRefreshApi(Resource): @with_current_tenant_id @with_session def post(self, session: Session, tenant_id: str, current_user: Account, agent_id: UUID): + args = AgentDebugConversationRefreshPayload.model_validate(request.get_json(silent=True) or {}) debug_conversation_id = _agent_roster_service(session).refresh_agent_app_debug_conversation_id( tenant_id=tenant_id, agent_id=str(agent_id), account_id=current_user.id, + draft_type=args.draft_type, ) return AgentDebugConversationRefreshResponse( debug_conversation_id=debug_conversation_id, diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index ae76fee38d9..3fe721def62 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -49,6 +49,7 @@ from libs import helper from libs.helper import uuid_value from libs.login import login_required from models import Account +from models.agent import AgentConfigDraftType from models.model import App, AppMode from services.agent.errors import AgentNotFoundError from services.agent.roster_service import AgentRosterService @@ -343,14 +344,23 @@ class AgentChatMessageStopApi(Resource): def _resolve_current_user_agent_debug_conversation_id( - *, session: Session, current_tenant_id: str, current_user: Account, app_model: App, agent_id: str | None + *, + session: Session, + current_tenant_id: str, + current_user: Account, + app_model: App, + agent_id: str | None, + draft_type: AgentConfigDraftType, ) -> str: + """Resolve the current editor's conversation without crossing draft surfaces.""" + roster_service = AgentRosterService(session) if agent_id: return roster_service.get_or_create_agent_app_debug_conversation_id( tenant_id=current_tenant_id, agent_id=agent_id, account_id=current_user.id, + draft_type=draft_type, ) agent = roster_service.get_app_backing_agent(tenant_id=current_tenant_id, app_id=str(app_model.id)) @@ -360,6 +370,7 @@ def _resolve_current_user_agent_debug_conversation_id( tenant_id=current_tenant_id, agent_id=agent.id, account_id=current_user.id, + draft_type=draft_type, ) @@ -382,6 +393,7 @@ def _create_chat_message( current_user=current_user, app_model=app_model, agent_id=agent_id, + draft_type=AgentConfigDraftType(args_model.draft_type), ) if args_model.conversation_id and args_model.conversation_id != debug_conversation_id: raise NotFound("Conversation Not Exists.") @@ -418,6 +430,7 @@ def _create_build_chat_finalization_message( current_user=current_user, app_model=app_model, agent_id=agent_id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, ) args: dict[str, Any] = { "query": _BUILD_CHAT_FINALIZATION_QUERY, diff --git a/api/migrations/versions/2026_07_22_1500-d2825e7b9c10_scope_agent_debug_conversations.py b/api/migrations/versions/2026_07_22_1500-d2825e7b9c10_scope_agent_debug_conversations.py new file mode 100644 index 00000000000..2e3e41e2b82 --- /dev/null +++ b/api/migrations/versions/2026_07_22_1500-d2825e7b9c10_scope_agent_debug_conversations.py @@ -0,0 +1,77 @@ +"""scope agent debug conversations by draft type + +Revision ID: d2825e7b9c10 +Revises: b8c9d0e1f2a3 +Create Date: 2026-07-22 15:00:00.000000 + +""" + +import sqlalchemy as sa +from alembic import op + +import models + +# revision identifiers, used by Alembic. +revision = "d2825e7b9c10" +down_revision = "b8c9d0e1f2a3" +branch_labels = None +depends_on = None + + +def upgrade(): + # Existing pointers have always represented Build chat because the Agent + # detail API exposes them as ``debug_conversation_id`` for that surface. + op.add_column( + "agent_debug_conversations", + sa.Column( + "draft_type", + sa.String(length=32), + nullable=False, + server_default=sa.text("'debug_build'"), + ), + ) + op.drop_constraint( + "agent_debug_conversation_agent_account_unique", + "agent_debug_conversations", + type_="unique", + ) + op.create_unique_constraint( + "agent_debug_conversation_agent_account_draft_type_unique", + "agent_debug_conversations", + ["tenant_id", "agent_id", "account_id", "draft_type"], + ) + + +def downgrade(): + debug_conversations = sa.table( + "agent_debug_conversations", + sa.column("tenant_id", models.types.StringUUID()), + sa.column("agent_id", models.types.StringUUID()), + sa.column("account_id", models.types.StringUUID()), + sa.column("draft_type", sa.String(length=32)), + ) + build_conversations = debug_conversations.alias("build_conversations") + op.get_bind().execute( + sa.delete(debug_conversations).where( + debug_conversations.c.draft_type == "draft", + sa.exists( + sa.select(sa.literal(1)).where( + build_conversations.c.tenant_id == debug_conversations.c.tenant_id, + build_conversations.c.agent_id == debug_conversations.c.agent_id, + build_conversations.c.account_id == debug_conversations.c.account_id, + build_conversations.c.draft_type == "debug_build", + ) + ), + ) + ) + op.drop_constraint( + "agent_debug_conversation_agent_account_draft_type_unique", + "agent_debug_conversations", + type_="unique", + ) + op.create_unique_constraint( + "agent_debug_conversation_agent_account_unique", + "agent_debug_conversations", + ["tenant_id", "agent_id", "account_id"], + ) + op.drop_column("agent_debug_conversations", "draft_type") diff --git a/api/models/agent.py b/api/models/agent.py index 467d7a4b753..cd3d371481d 100644 --- a/api/models/agent.py +++ b/api/models/agent.py @@ -222,11 +222,13 @@ class Agent(DefaultFieldsMixin, Base): class AgentDebugConversation(DefaultFieldsMixin, Base): - """Per-account console debug conversation for an Agent App. + """Per-account, per-draft console debug conversation for an Agent App. Agent App preview state must be isolated by editor account. The Agent row is shared by everyone in the workspace, so this table owns the user-specific - conversation pointer used by console debug chat. + conversation pointers used by console debug chat. ``draft`` is the Preview + conversation and ``debug_build`` is the Build conversation; they must never + share persisted messages or runtime sessions. """ __tablename__ = "agent_debug_conversations" @@ -236,7 +238,8 @@ class AgentDebugConversation(DefaultFieldsMixin, Base): "tenant_id", "agent_id", "account_id", - name="agent_debug_conversation_agent_account_unique", + "draft_type", + name="agent_debug_conversation_agent_account_draft_type_unique", ), Index("agent_debug_conversation_conversation_idx", "conversation_id"), Index("agent_debug_conversation_account_idx", "tenant_id", "account_id"), @@ -246,6 +249,12 @@ class AgentDebugConversation(DefaultFieldsMixin, Base): agent_id: Mapped[str] = mapped_column(StringUUID, nullable=False) app_id: Mapped[str] = mapped_column(StringUUID, nullable=False) account_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + draft_type: Mapped[AgentConfigDraftType] = mapped_column( + EnumText(AgentConfigDraftType, length=32), + nullable=False, + default=AgentConfigDraftType.DEBUG_BUILD, + server_default=sa.text("'debug_build'"), + ) conversation_id: Mapped[str] = mapped_column(StringUUID, nullable=False) diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index bc3768f4ae1..a5bb29e1917 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -959,6 +959,12 @@ Stop a running Agent App chat message generation | ---- | ---------- | ----------- | -------- | ------ | | agent_id | path | | Yes | string (uuid) | +#### Request Body + +| Required | Schema | +| -------- | ------ | +| No | **application/json**: [AgentDebugConversationRefreshPayload](#agentdebugconversationrefreshpayload)
| + #### Responses | Code | Description | Schema | @@ -13906,6 +13912,12 @@ Stable Agent Soul reference to one normalized skill archive. | date | string | | Yes | | message_count | integer | | Yes | +#### AgentDebugConversationRefreshPayload + +| Name | Type | Description | Required | +| ---- | ---- | ----------- | -------- | +| draft_type | [AgentConfigDraftType](#agentconfigdrafttype) | Agent draft surface whose conversation should be refreshed | No | + #### AgentDebugConversationRefreshResponse | Name | Type | Description | Required | diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index c6087843ae0..3d9401facf3 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -430,7 +430,11 @@ class AgentRosterService: agent.active_config_has_model = agent_soul_has_model(soul) agent.active_config_is_published = False self._session.flush() - self._get_or_create_agent_app_debug_conversation(agent=agent, account_id=account_id) + self._get_or_create_agent_app_debug_conversation( + agent=agent, + account_id=account_id, + draft_type=AgentConfigDraftType.DEBUG_BUILD, + ) return agent def create_hidden_backing_app_for_workflow_agent( @@ -527,7 +531,11 @@ class AgentRosterService: self._session.flush() return backing_app.id - def _get_or_create_agent_app_debug_conversation(self, *, agent: Agent, account_id: str) -> str: + def _get_or_create_agent_app_debug_conversation( + self, *, agent: Agent, account_id: str, draft_type: AgentConfigDraftType + ) -> str: + """Return the editor's conversation for one Agent draft surface.""" + backing_app_id = self._ensure_workflow_agent_backing_app(agent=agent, account_id=account_id) if not backing_app_id: raise AgentNotFoundError() @@ -537,6 +545,7 @@ class AgentRosterService: AgentDebugConversation.tenant_id == agent.tenant_id, AgentDebugConversation.agent_id == agent.id, AgentDebugConversation.account_id == account_id, + AgentDebugConversation.draft_type == draft_type, ) ) if mapping is not None: @@ -570,6 +579,7 @@ class AgentRosterService: agent_id=agent.id, app_id=backing_app_id, account_id=account_id, + draft_type=draft_type, conversation_id=conversation_id, ) ) @@ -577,9 +587,15 @@ class AgentRosterService: return conversation_id def get_or_create_agent_app_debug_conversation_id( - self, *, tenant_id: str, agent_id: str, account_id: str, commit: bool = True + self, + *, + tenant_id: str, + agent_id: str, + account_id: str, + draft_type: AgentConfigDraftType = AgentConfigDraftType.DEBUG_BUILD, + commit: bool = True, ) -> str: - """Return the current editor's debug conversation for an Agent App.""" + """Return the current editor's Build or Preview conversation for an Agent App.""" agent = self._session.scalar( select(Agent).where( @@ -591,13 +607,24 @@ class AgentRosterService: if agent is None: raise AgentNotFoundError() - conversation_id = self._get_or_create_agent_app_debug_conversation(agent=agent, account_id=account_id) + conversation_id = self._get_or_create_agent_app_debug_conversation( + agent=agent, + account_id=account_id, + draft_type=draft_type, + ) if commit: self._session.commit() return conversation_id - def load_agent_app_debug_conversation_id(self, *, tenant_id: str, agent_id: str, account_id: str) -> str | None: - """Return the current editor's existing debug conversation without creating or repairing rows.""" + def load_agent_app_debug_conversation_id( + self, + *, + tenant_id: str, + agent_id: str, + account_id: str, + draft_type: AgentConfigDraftType = AgentConfigDraftType.DEBUG_BUILD, + ) -> str | None: + """Return the editor's existing scoped conversation without creating or repairing rows.""" return self._session.scalar( select(Conversation.id) @@ -606,6 +633,7 @@ class AgentRosterService: AgentDebugConversation.tenant_id == tenant_id, AgentDebugConversation.agent_id == agent_id, AgentDebugConversation.account_id == account_id, + AgentDebugConversation.draft_type == draft_type, AgentDebugConversation.app_id == Conversation.app_id, Conversation.from_source == ConversationFromSource.CONSOLE, Conversation.from_account_id == account_id, @@ -626,16 +654,21 @@ class AgentRosterService: ) def refresh_agent_app_debug_conversation_id( - self, *, tenant_id: str, agent_id: str, account_id: str, commit: bool = True + self, + *, + tenant_id: str, + agent_id: str, + account_id: str, + draft_type: AgentConfigDraftType = AgentConfigDraftType.DEBUG_BUILD, + commit: bool = True, ) -> str: - """Start a new console debug conversation for the current Agent App editor. + """Start a new scoped console conversation for the current Agent App editor. - If this account already has a debug conversation mapping, the previous + If this account already has a mapping for the requested draft surface, the previous conversation is abandoned first: any ACTIVE conversation-owned Agent runtime sessions for that old conversation are sent through best-effort - backend cleanup and then retired locally even when enqueueing fails. - The debug mapping is then repointed to the freshly created - conversation. + backend cleanup and then retired locally even when enqueueing fails. The + other draft surface is left untouched. """ agent = self._session.scalar( @@ -663,6 +696,7 @@ class AgentRosterService: AgentDebugConversation.tenant_id == tenant_id, AgentDebugConversation.agent_id == agent_id, AgentDebugConversation.account_id == account_id, + AgentDebugConversation.draft_type == draft_type, ) ) if mapping is None: @@ -672,6 +706,7 @@ class AgentRosterService: agent_id=agent_id, app_id=backing_app_id, account_id=account_id, + draft_type=draft_type, conversation_id=conversation_id, ) ) @@ -683,6 +718,7 @@ class AgentRosterService: tenant_id=tenant_id, agent_id=agent_id, account_id=account_id, + draft_type=draft_type, app_id=previous_app_id or backing_app_id, conversation_id=previous_conversation_id, ) @@ -699,6 +735,7 @@ class AgentRosterService: tenant_id: str, agent_id: str, account_id: str, + draft_type: AgentConfigDraftType, app_id: str, conversation_id: str, ) -> None: @@ -727,7 +764,8 @@ class AgentRosterService: session_snapshot=stored_session.session_snapshot, runtime_layer_specs=stored_session.runtime_layer_specs, idempotency_key=( - f"{tenant_id}:{agent_id}:{account_id}:{conversation_id}:debug-session-cleanup:" + f"{tenant_id}:{agent_id}:{account_id}:{draft_type.value}:{conversation_id}:" + "debug-session-cleanup:" f"{stored_session.scope.agent_id}:" f"{stored_session.scope.agent_config_snapshot_id or 'no-config'}:" f"{stored_session.backend_run_id or 'no-run'}" @@ -738,6 +776,7 @@ class AgentRosterService: "conversation_id": stored_session.scope.conversation_id, "agent_id": stored_session.scope.agent_id, "agent_config_snapshot_id": stored_session.scope.agent_config_snapshot_id, + "draft_type": draft_type.value, "previous_agent_backend_run_id": stored_session.backend_run_id, }, ) @@ -772,9 +811,14 @@ class AgentRosterService: ) def load_or_create_agent_app_debug_conversation_ids_by_agent_id( - self, *, tenant_id: str, agents: list[Agent], account_id: str + self, + *, + tenant_id: str, + agents: list[Agent], + account_id: str, + draft_type: AgentConfigDraftType = AgentConfigDraftType.DEBUG_BUILD, ) -> dict[str, str]: - """Return per-account debug conversations for a page of Agent Apps.""" + """Return per-account scoped conversations for a page of Agent Apps.""" conversation_ids_by_agent_id: dict[str, str] = {} changed = False @@ -784,6 +828,7 @@ class AgentRosterService: conversation_ids_by_agent_id[agent.id] = self._get_or_create_agent_app_debug_conversation( agent=agent, account_id=account_id, + draft_type=draft_type, ) changed = True if changed: diff --git a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py index b026c0e0c85..ee13618fe1b 100644 --- a/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py +++ b/api/tests/unit_tests/controllers/console/agent/test_agent_controllers.py @@ -53,6 +53,7 @@ from controllers.console.app.message import ( AgentMessageFeedbackApi, AgentMessageSuggestedQuestionApi, ) +from models.agent import AgentConfigDraftType from services.entities.agent_entities import ComposerSaveStrategy, ComposerVariant @@ -371,6 +372,7 @@ def test_agent_app_list_and_create_use_agent_route( "tenant_id": "tenant-1", "agent_id": "agent-created", "account_id": account_id, + "draft_type": AgentConfigDraftType.DEBUG_BUILD, "commit": False, } @@ -544,8 +546,19 @@ def test_agent_app_copy_uses_agent_id_and_returns_agent_detail( } -def test_agent_debug_conversation_refresh_uses_current_user( - app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str +@pytest.mark.parametrize( + ("payload", "expected_draft_type"), + [ + (None, AgentConfigDraftType.DEBUG_BUILD), + ({"draft_type": "draft"}, AgentConfigDraftType.DRAFT), + ], +) +def test_agent_debug_conversation_refresh_uses_current_user_and_draft_type( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + account_id: str, + payload: dict[str, str] | None, + expected_draft_type: AgentConfigDraftType, ) -> None: agent_id = "00000000-0000-0000-0000-000000000001" captured: dict[str, object] = {} @@ -557,7 +570,9 @@ def test_agent_debug_conversation_refresh_uses_current_user( monkeypatch.setattr(roster_controller, "_agent_roster_service", lambda *_args: FakeRosterService()) with app.test_request_context( - "/console/api/agent/00000000-0000-0000-0000-000000000001/debug-conversation/refresh", method="POST" + "/console/api/agent/00000000-0000-0000-0000-000000000001/debug-conversation/refresh", + method="POST", + json=payload, ): response = unwrap(AgentDebugConversationRefreshApi.post)( AgentDebugConversationRefreshApi(), MagicMock(), "tenant-1", SimpleNamespace(id=account_id), agent_id @@ -567,7 +582,12 @@ def test_agent_debug_conversation_refresh_uses_current_user( "debug_conversation_has_messages": False, "debug_conversation_message_count": 0, } - assert captured == {"tenant_id": "tenant-1", "agent_id": agent_id, "account_id": account_id} + assert captured == { + "tenant_id": "tenant-1", + "agent_id": agent_id, + "account_id": account_id, + "draft_type": expected_draft_type, + } def test_agent_publish_and_build_draft_routes_call_composer_service( @@ -1456,6 +1476,7 @@ def test_build_chat_finalization_helper_forces_debug_build_and_push_prompt( "current_user": SimpleNamespace(id=account_id), "app_model": app_model, "agent_id": "agent-1", + "draft_type": AgentConfigDraftType.DEBUG_BUILD, } generate_call = cast(dict[str, object], captured["generate"]) assert generate_call["app_model"] is app_model @@ -1520,11 +1541,15 @@ def test_agent_chat_helper_forces_agent_streaming_and_external_trace( captured.update(kwargs) return {"answer": "ok"} + def resolve_debug_conversation(**kwargs: object) -> str: + captured["resolve_debug_conversation"] = kwargs + return "debug-conversation-1" + monkeypatch.setattr(completion_controller.AppGenerateService, "generate", generate) monkeypatch.setattr( completion_controller, "_resolve_current_user_agent_debug_conversation_id", - lambda **kwargs: "debug-conversation-1", + resolve_debug_conversation, ) monkeypatch.setattr( completion_controller.helper, "compact_generate_response", lambda response: {"response": response} @@ -1544,6 +1569,7 @@ def test_agent_chat_helper_forces_agent_streaming_and_external_trace( assert args["conversation_id"] == "debug-conversation-1" assert args["auto_generate_name"] is False assert args["external_trace_id"] == "trace-1" + assert cast(dict[str, object], captured["resolve_debug_conversation"])["draft_type"] == AgentConfigDraftType.DRAFT def test_agent_chat_helper_ignores_private_exit_intent_payload_key( @@ -1642,6 +1668,7 @@ def test_resolve_current_user_agent_debug_conversation_uses_agent_or_backing_app current_user=SimpleNamespace(id="account-1"), app_model=SimpleNamespace(id="app-1"), agent_id="agent-1", + draft_type=AgentConfigDraftType.DRAFT, ) fallback_id = completion_controller._resolve_current_user_agent_debug_conversation_id( session="session-1", # type: ignore[arg-type] @@ -1649,13 +1676,26 @@ def test_resolve_current_user_agent_debug_conversation_uses_agent_or_backing_app current_user=SimpleNamespace(id="account-1"), app_model=SimpleNamespace(id="app-1"), agent_id=None, + draft_type=AgentConfigDraftType.DEBUG_BUILD, ) assert explicit_id == "debug-agent-1" assert fallback_id == "debug-backing-agent" - assert calls[1] == {"get_or_create": {"tenant_id": "tenant-1", "agent_id": "agent-1", "account_id": "account-1"}} + assert calls[1] == { + "get_or_create": { + "tenant_id": "tenant-1", + "agent_id": "agent-1", + "account_id": "account-1", + "draft_type": AgentConfigDraftType.DRAFT, + } + } assert calls[3] == {"get_app_backing_agent": {"tenant_id": "tenant-1", "app_id": "app-1"}} assert calls[4] == { - "get_or_create": {"tenant_id": "tenant-1", "agent_id": "backing-agent", "account_id": "account-1"} + "get_or_create": { + "tenant_id": "tenant-1", + "agent_id": "backing-agent", + "account_id": "account-1", + "draft_type": AgentConfigDraftType.DEBUG_BUILD, + } } diff --git a/api/tests/unit_tests/controllers/test_swagger.py b/api/tests/unit_tests/controllers/test_swagger.py index 667058de95e..149af9ff76a 100644 --- a/api/tests/unit_tests/controllers/test_swagger.py +++ b/api/tests/unit_tests/controllers/test_swagger.py @@ -574,6 +574,27 @@ def test_console_account_avatar_query_param_renders_as_query(monkeypatch: pytest assert params["avatar"]["required"] is True +def test_console_agent_debug_conversation_refresh_body_is_optional(monkeypatch: pytest.MonkeyPatch): + from configs import dify_config + from controllers.console import bp as console_bp + + monkeypatch.setattr(dify_config, "SWAGGER_UI_ENABLED", True) + + app = Flask(__name__) + app.config["TESTING"] = True + app.register_blueprint(console_bp) + + payload = app.test_client().get("/console/api/openapi.json").get_json() + operation = payload["paths"]["/agent/{agent_id}/debug-conversation/refresh"]["post"] + request_body = operation["requestBody"] + + assert request_body["required"] is False + assert request_body["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/AgentDebugConversationRefreshPayload" + } + assert "AgentDebugConversationRefreshPayload" in payload["components"]["schemas"] + + def test_console_member_invite_documents_bad_request_response(monkeypatch: pytest.MonkeyPatch): from configs import dify_config from controllers.console import bp as console_bp 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 eb8f87fc1a9..a70eb496da6 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -2624,7 +2624,7 @@ def test_roster_create_detail_and_lookup_helpers(monkeypatch: pytest.MonkeyPatch monkeypatch.setattr( AgentRosterService, "_get_or_create_agent_app_debug_conversation", - lambda self, *, agent, account_id: "debug-conversation-1", + lambda self, *, agent, account_id, draft_type: "debug-conversation-1", ) payload = roster_service.RosterAgentCreatePayload( name="Analyst", @@ -2730,6 +2730,7 @@ def test_agent_app_debug_conversation_create_reuse_and_recreate(): 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 assert create_session.commits == 1 existing_mapping = AgentDebugConversation( @@ -2737,6 +2738,7 @@ def test_agent_app_debug_conversation_create_reuse_and_recreate(): agent_id="agent-1", app_id="app-1", account_id="account-1", + draft_type=AgentConfigDraftType.DEBUG_BUILD, conversation_id="existing-conversation", ) reuse_session = FakeSession(scalar=[agent, existing_mapping, "existing-conversation"]) @@ -2754,6 +2756,7 @@ def test_agent_app_debug_conversation_create_reuse_and_recreate(): agent_id="agent-1", app_id="app-1", account_id="account-1", + draft_type=AgentConfigDraftType.DEBUG_BUILD, conversation_id="deleted-conversation", ) recreate_session = FakeSession(scalar=[agent, stale_mapping, None]) @@ -2768,6 +2771,42 @@ def test_agent_app_debug_conversation_create_reuse_and_recreate(): assert recreate_session.commits == 1 +def test_agent_app_debug_conversations_are_isolated_by_draft_type(): + 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 = FakeSession(scalar=[agent, None, agent, None]) + service = AgentRosterService(session) + + build_conversation_id = service.get_or_create_agent_app_debug_conversation_id( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + draft_type=AgentConfigDraftType.DEBUG_BUILD, + ) + preview_conversation_id = service.get_or_create_agent_app_debug_conversation_id( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + draft_type=AgentConfigDraftType.DRAFT, + ) + + mappings = [value for value in session.added if isinstance(value, AgentDebugConversation)] + 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(): session = FakeSession(scalar=[3]) @@ -2794,6 +2833,7 @@ def test_agent_app_debug_conversation_requires_app_binding(): AgentRosterService(FakeSession())._get_or_create_agent_app_debug_conversation( agent=agent, account_id="account-1", + draft_type=AgentConfigDraftType.DEBUG_BUILD, ) @@ -2840,7 +2880,9 @@ def test_load_or_create_agent_app_debug_conversations_supports_runtime_backed_ag assert result["agent-1"] assert result["agent-3"] assert fake_session.commits == 1 - assert len([value for value in fake_session.added if isinstance(value, AgentDebugConversation)]) == 2 + mappings = [value for value in fake_session.added if isinstance(value, AgentDebugConversation)] + 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(): @@ -3276,6 +3318,7 @@ class TestAgentAppBackingAgent: 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 assert session.deleted == [] assert session.commits == 1 @@ -3361,8 +3404,10 @@ class TestAgentAppBackingAgent: payload = cleanup_delay.call_args.args[0] assert payload["metadata"]["conversation_id"] == "old-conversation" assert payload["metadata"]["agent_id"] == "agent-9" + assert payload["metadata"]["draft_type"] == "debug_build" assert ( - payload["idempotency_key"] == "tenant-1:agent-1:account-1:old-conversation:debug-session-cleanup:" + payload["idempotency_key"] + == "tenant-1:agent-1:account-1:debug_build:old-conversation:debug-session-cleanup:" "agent-9:snap-9:run-old" ) cleanup_store.mark_cleaned.assert_called_once_with( diff --git a/packages/contracts/generated/api/console/agent/orpc.gen.ts b/packages/contracts/generated/api/console/agent/orpc.gen.ts index 2cc590c433d..12b58ef4188 100644 --- a/packages/contracts/generated/api/console/agent/orpc.gen.ts +++ b/packages/contracts/generated/api/console/agent/orpc.gen.ts @@ -143,6 +143,7 @@ import { zPostAgentByAgentIdCopyBody, zPostAgentByAgentIdCopyPath, zPostAgentByAgentIdCopyResponse, + zPostAgentByAgentIdDebugConversationRefreshBody, zPostAgentByAgentIdDebugConversationRefreshPath, zPostAgentByAgentIdDebugConversationRefreshResponse, zPostAgentByAgentIdFeaturesBody, @@ -852,7 +853,12 @@ export const post12 = oc path: '/agent/{agent_id}/debug-conversation/refresh', tags: ['console'], }) - .input(z.object({ params: zPostAgentByAgentIdDebugConversationRefreshPath })) + .input( + z.object({ + body: zPostAgentByAgentIdDebugConversationRefreshBody.optional(), + params: zPostAgentByAgentIdDebugConversationRefreshPath, + }), + ) .output(zPostAgentByAgentIdDebugConversationRefreshResponse) export const refresh = { diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 2a5e0dcb3b2..98fda2ccab9 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -282,6 +282,10 @@ export type AgentAppCopyPayload = { role?: string | null } +export type AgentDebugConversationRefreshPayload = { + draft_type?: AgentConfigDraftType +} + export type AgentDebugConversationRefreshResponse = { debug_conversation_has_messages?: boolean debug_conversation_id: string @@ -818,6 +822,8 @@ export type AgentConfigSkillMarkdownResponse = { truncated: boolean } +export type AgentConfigDraftType = 'debug_build' | 'draft' + export type AgentDriveItemResponse = { created_at?: number | null file_kind: string @@ -1221,8 +1227,6 @@ export type AgentSoulToolsConfig = { dify_tools?: Array } -export type AgentConfigDraftType = 'debug_build' | 'draft' - export type DeclaredOutputConfig = { array_item?: DeclaredArrayItem | null check?: DeclaredOutputCheckConfig | null @@ -2753,7 +2757,7 @@ export type PostAgentByAgentIdCopyResponse = PostAgentByAgentIdCopyResponses[keyof PostAgentByAgentIdCopyResponses] export type PostAgentByAgentIdDebugConversationRefreshData = { - body?: never + body?: AgentDebugConversationRefreshPayload path: { agent_id: string } diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index 085f6551390..4146d261ffc 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -651,6 +651,45 @@ export const zAgentConfigSkillInspectResponse = z.object({ warnings: z.array(z.string()).optional(), }) +/** + * AgentConfigDraftType + * + * Editable Agent Soul draft workspace type. + */ +export const zAgentConfigDraftType = z.enum(['debug_build', 'draft']) + +/** + * AgentDebugConversationRefreshPayload + */ +export const zAgentDebugConversationRefreshPayload = z.object({ + draft_type: zAgentConfigDraftType.optional().default('debug_build'), +}) + +/** + * AgentConfigDraftSummaryResponse + */ +export const zAgentConfigDraftSummaryResponse = z.object({ + account_id: z.string().nullish(), + agent_id: z.string(), + base_snapshot_id: z.string().nullish(), + created_at: z.int().nullish(), + created_by: z.string().nullish(), + draft_type: zAgentConfigDraftType, + id: z.string(), + updated_at: z.int().nullish(), + updated_by: z.string().nullish(), +}) + +/** + * AgentPublishResponse + */ +export const zAgentPublishResponse = z.object({ + active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(), + active_config_snapshot_id: z.string(), + draft: zAgentConfigDraftSummaryResponse.nullish(), + result: z.string(), +}) + /** * AgentDriveItemResponse */ @@ -1240,38 +1279,6 @@ export const zAgentSoulPromptConfig = z.object({ system_prompt: z.string().optional().default(''), }) -/** - * AgentConfigDraftType - * - * Editable Agent Soul draft workspace type. - */ -export const zAgentConfigDraftType = z.enum(['debug_build', 'draft']) - -/** - * AgentConfigDraftSummaryResponse - */ -export const zAgentConfigDraftSummaryResponse = z.object({ - account_id: z.string().nullish(), - agent_id: z.string(), - base_snapshot_id: z.string().nullish(), - created_at: z.int().nullish(), - created_by: z.string().nullish(), - draft_type: zAgentConfigDraftType, - id: z.string(), - updated_at: z.int().nullish(), - updated_by: z.string().nullish(), -}) - -/** - * AgentPublishResponse - */ -export const zAgentPublishResponse = z.object({ - active_config_snapshot: zAgentConfigSnapshotSummaryResponse.nullish(), - active_config_snapshot_id: z.string(), - draft: zAgentConfigDraftSummaryResponse.nullish(), - result: z.string(), -}) - /** * AgentHumanContactConfig */ @@ -3257,6 +3264,8 @@ export const zPostAgentByAgentIdCopyPath = z.object({ */ export const zPostAgentByAgentIdCopyResponse = zAgentAppDetailWithSite +export const zPostAgentByAgentIdDebugConversationRefreshBody = zAgentDebugConversationRefreshPayload + export const zPostAgentByAgentIdDebugConversationRefreshPath = z.object({ agent_id: z.uuid(), })