From b67a04aa2259f4a272869d68865e7630364eb88b Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Tue, 23 Jun 2026 10:04:23 +0800 Subject: [PATCH] fix: isolate agent debug conversations by account (#37766) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/agent/roster.py | 37 ++-- api/controllers/console/app/completion.py | 51 ++++- api/controllers/console/app/message.py | 33 +++- ...6b2d3e1_add_agent_debug_conversation_id.py | 50 ++++- api/models/__init__.py | 2 + api/models/agent.py | 30 ++- api/services/agent/roster_service.py | 102 +++++++++- .../console/agent/test_agent_controllers.py | 185 +++++++++++++++++- .../services/agent/test_agent_services.py | 144 +++++++++++++- 9 files changed, 583 insertions(+), 51 deletions(-) diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index fa7ab9726f6..96bce6763f5 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -271,7 +271,7 @@ def _agent_roster_service() -> AgentRosterService: return AgentRosterService(db.session) -def _serialize_agent_app_detail(app_model) -> dict: +def _serialize_agent_app_detail(app_model, *, current_user: Account) -> dict: """Serialize an Agent App detail using roster-only DTOs. `/agent` responses are roster-shaped rather than raw app-shaped: `id` @@ -294,7 +294,11 @@ def _serialize_agent_app_detail(app_model) -> dict: payload.pop("bound_agent_id", None) payload["app_id"] = str(app_model.id) payload["id"] = agent.id - payload["debug_conversation_id"] = agent.debug_conversation_id + payload["debug_conversation_id"] = roster_service.get_or_create_agent_app_debug_conversation_id( + tenant_id=app_model.tenant_id, + agent_id=agent.id, + account_id=current_user.id, + ) payload["role"] = agent.role or "" payload["active_config_is_published"] = roster_service.active_config_is_published( tenant_id=app_model.tenant_id, @@ -303,7 +307,7 @@ def _serialize_agent_app_detail(app_model) -> dict: return payload -def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict: +def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str, current_user: Account) -> dict: """Serialize Agent App lists with roster-shaped items. Each item starts from the shared App list shape, then drops @@ -326,6 +330,11 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict: tenant_id=tenant_id, agent_ids=[agent.id for agent in agents_by_app_id.values()], ) + debug_conversation_ids_by_agent_id = roster_service.load_or_create_agent_app_debug_conversation_ids_by_agent_id( + tenant_id=tenant_id, + agents=list(agents_by_app_id.values()), + account_id=current_user.id, + ) payload = AgentAppPagination.model_validate(app_pagination, from_attributes=True).model_dump(mode="json") for item in payload["data"]: app_id = item["id"] @@ -334,7 +343,7 @@ def _serialize_agent_app_pagination(app_pagination, *, tenant_id: str) -> dict: if agent: item["app_id"] = app_id item["id"] = agent.id - item["debug_conversation_id"] = agent.debug_conversation_id + item["debug_conversation_id"] = debug_conversation_ids_by_agent_id.get(agent.id) item["role"] = agent.role or "" item["active_config_is_published"] = active_config_is_published_by_agent_id.get(agent.id, False) published_references = published_references_by_agent_id.get(agent.id, []) @@ -442,7 +451,11 @@ class AgentAppListApi(Resource): empty = AgentAppPagination(page=args.page, limit=args.limit, total=0, has_more=False, data=[]) return empty.model_dump(mode="json") - return _serialize_agent_app_pagination(app_pagination, tenant_id=current_tenant_id) + return _serialize_agent_app_pagination( + app_pagination, + tenant_id=current_tenant_id, + current_user=current_user, + ) @console_ns.expect(console_ns.models[AgentAppCreatePayload.__name__]) @console_ns.response(201, "Agent app created successfully", console_ns.models[AgentAppDetailWithSite.__name__]) @@ -467,7 +480,7 @@ class AgentAppListApi(Resource): ) app = AppService().create_app(current_tenant_id, params, current_user) - return _serialize_agent_app_detail(app), 201 + return _serialize_agent_app_detail(app, current_user=current_user), 201 @console_ns.route("/agent/") @@ -477,10 +490,11 @@ class AgentAppApi(Resource): @login_required @account_initialization_required @enterprise_license_required + @with_current_user @with_current_tenant_id - def get(self, tenant_id: str, agent_id: UUID): + def get(self, tenant_id: str, current_user: Account, agent_id: UUID): app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) - return _serialize_agent_app_detail(app_model) + return _serialize_agent_app_detail(app_model, current_user=current_user) @console_ns.expect(console_ns.models[AgentAppUpdatePayload.__name__]) @console_ns.response(200, "Agent app updated successfully", console_ns.models[AgentAppDetailWithSite.__name__]) @@ -490,8 +504,9 @@ class AgentAppApi(Resource): @login_required @account_initialization_required @edit_permission_required + @with_current_user @with_current_tenant_id - def put(self, tenant_id: str, agent_id: UUID): + def put(self, tenant_id: str, current_user: Account, agent_id: UUID): app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) args = AgentAppUpdatePayload.model_validate(console_ns.payload) args_dict: AppService.ArgsDict = { @@ -505,7 +520,7 @@ class AgentAppApi(Resource): "role": args.role, } updated = AppService().update_app(app_model, args_dict) - return _serialize_agent_app_detail(updated) + return _serialize_agent_app_detail(updated, current_user=current_user) @console_ns.response(204, "Agent app deleted successfully") @console_ns.response(403, "Insufficient permissions") @@ -544,7 +559,7 @@ class AgentAppCopyApi(Resource): icon=args.icon, icon_background=args.icon_background, ) - return _serialize_agent_app_detail(copied_app), 201 + return _serialize_agent_app_detail(copied_app, current_user=current_user), 201 @console_ns.route("/agent//api-access") diff --git a/api/controllers/console/app/completion.py b/api/controllers/console/app/completion.py index 62b95ad22e4..545fad34cde 100644 --- a/api/controllers/console/app/completion.py +++ b/api/controllers/console/app/completion.py @@ -40,12 +40,15 @@ from core.errors.error import ( QuotaExceededError, ) from core.helper.trace_id_helper import get_external_trace_id +from extensions.ext_database import db from graphon.model_runtime.errors.invoke import InvokeError from libs import helper from libs.helper import uuid_value from libs.login import login_required from models import Account from models.model import App, AppMode +from services.agent.errors import AgentNotFoundError +from services.agent.roster_service import AgentRosterService from services.app_generate_service import AppGenerateService from services.app_task_service import AppTaskService from services.errors.llm import InvokeRateLimitError @@ -191,10 +194,11 @@ class ChatMessageApi(Resource): @account_initialization_required @edit_permission_required @with_current_user + @with_current_tenant_id @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN) @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.AGENT]) - def post(self, current_user: Account, app_model: App): - return _create_chat_message(current_user=current_user, app_model=app_model) + def post(self, current_tenant_id: str, current_user: Account, app_model: App): + return _create_chat_message(current_tenant_id=current_tenant_id, current_user=current_user, app_model=app_model) @console_ns.route("/agent//chat-messages") @@ -215,7 +219,12 @@ class AgentChatMessageApi(Resource): @with_current_tenant_id def post(self, current_tenant_id: str, current_user: Account, agent_id: UUID): app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) - return _create_chat_message(current_user=current_user, app_model=app_model) + return _create_chat_message( + current_tenant_id=current_tenant_id, + current_user=current_user, + app_model=app_model, + agent_id=str(agent_id), + ) @console_ns.route("/apps//chat-messages//stop") @@ -249,11 +258,45 @@ class AgentChatMessageStopApi(Resource): return _stop_chat_message(current_user_id=current_user_id, app_model=app_model, task_id=task_id) -def _create_chat_message(*, current_user: Account, app_model: App): +def _resolve_current_user_agent_debug_conversation_id( + *, current_tenant_id: str, current_user: Account, app_model: App, agent_id: str | None +) -> str: + roster_service = AgentRosterService(db.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, + ) + + agent = roster_service.get_app_backing_agent(tenant_id=current_tenant_id, app_id=str(app_model.id)) + if agent is None: + raise AgentNotFoundError() + 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, + ) + + +def _create_chat_message( + *, current_user: Account, app_model: App, current_tenant_id: str | None = None, agent_id: str | None = None +): raw_payload = console_ns.payload or {} args_model = ChatMessagePayload.model_validate(raw_payload) args = args_model.model_dump(exclude_none=True, by_alias=True) + if AppMode.value_of(app_model.mode) == AppMode.AGENT: + debug_conversation_id = _resolve_current_user_agent_debug_conversation_id( + current_tenant_id=current_tenant_id or app_model.tenant_id, + current_user=current_user, + app_model=app_model, + agent_id=agent_id, + ) + if args_model.conversation_id and args_model.conversation_id != debug_conversation_id: + raise NotFound("Conversation Not Exists.") + args["conversation_id"] = debug_conversation_id + streaming = _resolve_debugger_chat_streaming( app_mode=AppMode.value_of(app_model.mode), response_mode=args_model.response_mode, diff --git a/api/controllers/console/app/message.py b/api/controllers/console/app/message.py index 9944f02207f..726bd94cd7e 100644 --- a/api/controllers/console/app/message.py +++ b/api/controllers/console/app/message.py @@ -53,6 +53,7 @@ from libs.login import login_required from models.account import Account from models.enums import FeedbackFromSource, FeedbackRating from models.model import App, AppMode, Conversation, Message, MessageAnnotation, MessageFeedback +from services.conversation_service import ConversationService from services.errors.conversation import ConversationNotExistsError from services.errors.message import MessageNotExistsError, SuggestedQuestionsAfterAnswerDisabledError from services.message_service import MessageService, attach_message_extra_contents @@ -186,10 +187,11 @@ class ChatMessageListApi(Resource): @account_initialization_required @setup_required @edit_permission_required + @with_current_user @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) @get_app_model(mode=[AppMode.CHAT, AppMode.AGENT_CHAT, AppMode.ADVANCED_CHAT, AppMode.AGENT]) - def get(self, app_model: App): - return _list_chat_messages(app_model=app_model) + def get(self, current_user: Account, app_model: App): + return _list_chat_messages(app_model=app_model, current_user=current_user) @console_ns.route("/agent//chat-messages") @@ -205,10 +207,11 @@ class AgentChatMessageListApi(Resource): @setup_required @edit_permission_required @rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_VIEW_LAYOUT) + @with_current_user @with_current_tenant_id - def get(self, current_tenant_id: str, agent_id: UUID): + def get(self, current_tenant_id: str, current_user: Account, agent_id: UUID): app_model = resolve_agent_app_model(tenant_id=current_tenant_id, agent_id=agent_id) - return _list_chat_messages(app_model=app_model) + return _list_chat_messages(app_model=app_model, current_user=current_user) @console_ns.route("/apps//feedbacks") @@ -390,14 +393,24 @@ class AgentMessageApi(Resource): return _get_message_detail(app_model=app_model, message_id=message_id) -def _list_chat_messages(*, app_model: App): +def _list_chat_messages(*, app_model: App, current_user: Account | None = None): args = ChatMessagesQuery.model_validate(request.args.to_dict()) - conversation = db.session.scalar( - select(Conversation) - .where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id) - .limit(1) - ) + if AppMode.value_of(app_model.mode) == AppMode.AGENT and current_user is not None: + try: + conversation = ConversationService.get_conversation( + app_model=app_model, + conversation_id=args.conversation_id, + user=current_user, + ) + except ConversationNotExistsError: + raise NotFound("Conversation Not Exists.") + else: + conversation = db.session.scalar( + select(Conversation) + .where(Conversation.id == args.conversation_id, Conversation.app_id == app_model.id) + .limit(1) + ) if not conversation: raise NotFound("Conversation Not Exists.") diff --git a/api/migrations/versions/2026_06_22_1000-c8f4a6b2d3e1_add_agent_debug_conversation_id.py b/api/migrations/versions/2026_06_22_1000-c8f4a6b2d3e1_add_agent_debug_conversation_id.py index 8e0ea3284c9..213b8d36978 100644 --- a/api/migrations/versions/2026_06_22_1000-c8f4a6b2d3e1_add_agent_debug_conversation_id.py +++ b/api/migrations/versions/2026_06_22_1000-c8f4a6b2d3e1_add_agent_debug_conversation_id.py @@ -1,4 +1,4 @@ -"""add agent debug conversation id +"""add agent debug conversations Revision ID: c8f4a6b2d3e1 Revises: b2515f9d4c2a @@ -18,13 +18,49 @@ branch_labels = None depends_on = None +def _is_pg(conn) -> bool: + return conn.dialect.name == "postgresql" + + +def _uuid_column(name: str, *, nullable: bool = False, primary_key: bool = False) -> sa.Column: + kwargs = {"nullable": nullable, "primary_key": primary_key} + if primary_key and _is_pg(op.get_bind()): + kwargs["server_default"] = sa.text("uuidv7()") + return sa.Column(name, models.types.StringUUID(), **kwargs) + + def upgrade(): - with op.batch_alter_table("agents", schema=None) as batch_op: - batch_op.add_column(sa.Column("debug_conversation_id", models.types.StringUUID(), nullable=True)) - batch_op.create_index("agent_debug_conversation_id_idx", ["debug_conversation_id"], unique=False) + op.create_table( + "agent_debug_conversations", + _uuid_column("id", primary_key=True), + sa.Column("tenant_id", models.types.StringUUID(), nullable=False), + sa.Column("agent_id", models.types.StringUUID(), nullable=False), + sa.Column("app_id", models.types.StringUUID(), nullable=False), + sa.Column("account_id", models.types.StringUUID(), nullable=False), + sa.Column("conversation_id", models.types.StringUUID(), nullable=False), + sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("agent_debug_conversation_pkey")), + sa.UniqueConstraint( + "tenant_id", + "agent_id", + "account_id", + name=op.f("agent_debug_conversation_agent_account_unique"), + ), + ) + op.create_index( + "agent_debug_conversation_conversation_idx", + "agent_debug_conversations", + ["conversation_id"], + ) + op.create_index( + "agent_debug_conversation_account_idx", + "agent_debug_conversations", + ["tenant_id", "account_id"], + ) def downgrade(): - with op.batch_alter_table("agents", schema=None) as batch_op: - batch_op.drop_index("agent_debug_conversation_id_idx") - batch_op.drop_column("debug_conversation_id") + op.drop_index("agent_debug_conversation_account_idx", table_name="agent_debug_conversations") + op.drop_index("agent_debug_conversation_conversation_idx", table_name="agent_debug_conversations") + op.drop_table("agent_debug_conversations") diff --git a/api/models/__init__.py b/api/models/__init__.py index 78ca43fa374..9992de982c4 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -13,6 +13,7 @@ from .agent import ( AgentConfigRevision, AgentConfigRevisionOperation, AgentConfigSnapshot, + AgentDebugConversation, AgentDriveFile, AgentDriveFileKind, AgentIconType, @@ -156,6 +157,7 @@ __all__ = [ "AgentConfigRevision", "AgentConfigRevisionOperation", "AgentConfigSnapshot", + "AgentDebugConversation", "AgentDriveFile", "AgentDriveFileKind", "AgentIconType", diff --git a/api/models/agent.py b/api/models/agent.py index 6500eecafea..46044edd5e7 100644 --- a/api/models/agent.py +++ b/api/models/agent.py @@ -135,7 +135,6 @@ class Agent(DefaultFieldsMixin, Base): Index("agent_tenant_workflow_id_idx", "tenant_id", "workflow_id"), Index("agent_tenant_app_id_idx", "tenant_id", "app_id"), Index("agent_active_config_snapshot_id_idx", "active_config_snapshot_id"), - Index("agent_debug_conversation_id_idx", "debug_conversation_id"), Index( "agent_tenant_invitable_idx", "tenant_id", @@ -163,7 +162,6 @@ class Agent(DefaultFieldsMixin, Base): scope: Mapped[AgentScope] = mapped_column(EnumText(AgentScope, length=32), nullable=False) source: Mapped[AgentSource] = mapped_column(EnumText(AgentSource, length=32), nullable=False) app_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) - debug_conversation_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) workflow_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) workflow_node_id: Mapped[str | None] = mapped_column(String(255), nullable=True) active_config_snapshot_id: Mapped[str | None] = mapped_column(StringUUID, nullable=True) @@ -184,6 +182,34 @@ class Agent(DefaultFieldsMixin, Base): archived_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) +class AgentDebugConversation(DefaultFieldsMixin, Base): + """Per-account 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. + """ + + __tablename__ = "agent_debug_conversations" + __table_args__ = ( + sa.PrimaryKeyConstraint("id", name="agent_debug_conversation_pkey"), + UniqueConstraint( + "tenant_id", + "agent_id", + "account_id", + name="agent_debug_conversation_agent_account_unique", + ), + Index("agent_debug_conversation_conversation_idx", "conversation_id"), + Index("agent_debug_conversation_account_idx", "tenant_id", "account_id"), + ) + + tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + 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) + conversation_id: Mapped[str] = mapped_column(StringUUID, nullable=False) + + class AgentConfigSnapshot(DefaultFieldsMixin, Base): """Immutable Agent Soul snapshot. diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index 1ec4f29e6bb..e78d49c65b7 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -11,6 +11,7 @@ from models.agent import ( AgentConfigRevision, AgentConfigRevisionOperation, AgentConfigSnapshot, + AgentDebugConversation, AgentKind, AgentScope, AgentSource, @@ -97,7 +98,7 @@ class AgentRosterService: "scope": agent.scope.value, "source": agent.source.value, "app_id": agent.app_id, - "debug_conversation_id": getattr(agent, "debug_conversation_id", None), + "debug_conversation_id": None, "workflow_id": agent.workflow_id, "workflow_node_id": agent.workflow_node_id, "active_config_snapshot_id": agent.active_config_snapshot_id, @@ -393,15 +394,12 @@ class AgentRosterService: self._session.add(revision) agent.active_config_snapshot_id = version.id agent.active_config_has_model = agent_soul_has_model(AgentSoulConfig()) - agent.debug_conversation_id = self._create_agent_app_debug_conversation( - app_id=app_id, - account_id=account_id, - ) self._session.flush() + self._get_or_create_agent_app_debug_conversation(agent=agent, account_id=account_id) return agent def _create_agent_app_debug_conversation(self, *, app_id: str, account_id: str) -> str: - """Create the stable console conversation used by Agent App debug mode.""" + """Create one console debug conversation for an Agent App editor.""" conversation = Conversation( app_id=app_id, @@ -425,6 +423,98 @@ class AgentRosterService: self._session.flush() return conversation.id + def _get_or_create_agent_app_debug_conversation(self, *, agent: Agent, account_id: str) -> str: + if not agent.app_id: + raise AgentNotFoundError() + + mapping = self._session.scalar( + select(AgentDebugConversation).where( + AgentDebugConversation.tenant_id == agent.tenant_id, + AgentDebugConversation.agent_id == agent.id, + AgentDebugConversation.account_id == account_id, + ) + ) + if mapping is not None: + conversation_id = self._session.scalar( + select(Conversation.id).where( + Conversation.id == mapping.conversation_id, + Conversation.app_id == agent.app_id, + Conversation.from_source == ConversationFromSource.CONSOLE, + Conversation.from_account_id == account_id, + Conversation.is_deleted.is_(False), + ) + ) + if conversation_id: + return conversation_id + + mapping.conversation_id = self._create_agent_app_debug_conversation( + app_id=agent.app_id, + account_id=account_id, + ) + self._session.flush() + return mapping.conversation_id + + conversation_id = self._create_agent_app_debug_conversation( + app_id=agent.app_id, + account_id=account_id, + ) + self._session.add( + AgentDebugConversation( + tenant_id=agent.tenant_id, + agent_id=agent.id, + app_id=agent.app_id, + account_id=account_id, + conversation_id=conversation_id, + ) + ) + self._session.flush() + 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 + ) -> str: + """Return the current editor's debug conversation for an Agent App.""" + + agent = self._session.scalar( + select(Agent).where( + Agent.tenant_id == tenant_id, + Agent.id == agent_id, + Agent.scope == AgentScope.ROSTER, + Agent.source == AgentSource.AGENT_APP, + Agent.status == AgentStatus.ACTIVE, + ) + ) + if agent is None: + raise AgentNotFoundError() + + conversation_id = self._get_or_create_agent_app_debug_conversation(agent=agent, account_id=account_id) + if commit: + self._session.commit() + return conversation_id + + def load_or_create_agent_app_debug_conversation_ids_by_agent_id( + self, *, tenant_id: str, agents: list[Agent], account_id: str + ) -> dict[str, str]: + """Return per-account debug conversations for a page of Agent Apps.""" + + conversation_ids_by_agent_id: dict[str, str] = {} + changed = False + for agent in agents: + if ( + agent.tenant_id != tenant_id + or agent.scope != AgentScope.ROSTER + or agent.source != AgentSource.AGENT_APP + ): + continue + conversation_ids_by_agent_id[agent.id] = self._get_or_create_agent_app_debug_conversation( + agent=agent, + account_id=account_id, + ) + changed = True + if changed: + self._session.commit() + return conversation_ids_by_agent_id + def load_app_backing_agents_by_app_id(self, *, tenant_id: str, app_ids: list[str]) -> dict[str, Agent]: """Return active app-backed Agents keyed by Agent App id.""" if not app_ids: 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 65ab0014d1a..02fd5da55ac 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 @@ -246,6 +246,16 @@ def test_agent_app_list_and_create_use_agent_route( active_config_snapshot_id=None, ), ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_or_create_agent_app_debug_conversation_id", + lambda _self, **kwargs: "debug-conversation-detail", + ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_or_create_agent_app_debug_conversation_id", + lambda _self, **kwargs: "debug-conversation-detail", + ) monkeypatch.setattr( roster_controller.AgentRosterService, "load_published_references_by_agent_id", @@ -266,6 +276,16 @@ def test_agent_app_list_and_create_use_agent_route( ] }, ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "load_or_create_agent_app_debug_conversation_ids_by_agent_id", + lambda _self, **kwargs: {"agent-list": "debug-conversation-list"}, + ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_or_create_agent_app_debug_conversation_id", + lambda _self, **kwargs: "debug-conversation-created", + ) monkeypatch.setattr( roster_controller.FeatureService, "get_system_features", @@ -362,6 +382,11 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id( active_config_snapshot_id=None, ), ) + monkeypatch.setattr( + roster_controller.AgentRosterService, + "get_or_create_agent_app_debug_conversation_id", + lambda _self, **kwargs: "debug-conversation-detail", + ) monkeypatch.setattr( roster_controller.FeatureService, "get_system_features", @@ -382,7 +407,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id( monkeypatch.setattr(roster_controller, "AppService", FakeAppService) - detail = unwrap(AgentAppApi.get)(AgentAppApi(), "tenant-1", agent_id) + detail = unwrap(AgentAppApi.get)(AgentAppApi(), "tenant-1", SimpleNamespace(id=account_id), agent_id) assert detail["id"] == agent_id assert detail["app_id"] == "app-1" assert detail["debug_conversation_id"] == "debug-conversation-detail" @@ -394,7 +419,7 @@ def test_agent_app_detail_update_delete_resolve_app_from_agent_id( "/console/api/agent/00000000-0000-0000-0000-000000000001", json={"name": "Renamed", "description": "", "role": "Reviewer", "icon_type": "emoji", "icon": "R"}, ): - updated = unwrap(AgentAppApi.put)(AgentAppApi(), "tenant-1", agent_id) + updated = unwrap(AgentAppApi.put)(AgentAppApi(), "tenant-1", SimpleNamespace(id=account_id), agent_id) assert updated["name"] == "Renamed" assert updated["id"] == agent_id @@ -429,7 +454,7 @@ def test_agent_app_copy_uses_agent_id_and_returns_agent_detail( monkeypatch.setattr( roster_controller, "_serialize_agent_app_detail", - lambda app_model: {"id": "copied-agent", "app_id": app_model.id, "name": app_model.name}, + lambda app_model, **_kwargs: {"id": "copied-agent", "app_id": app_model.id, "name": app_model.name}, ) with app.test_request_context( @@ -620,7 +645,7 @@ def test_agent_app_update_rejects_empty_role(app: Flask, monkeypatch: pytest.Mon json={"name": "Renamed", "description": "", "role": "", "icon_type": "emoji", "icon": "R"}, ): with pytest.raises(ValueError, match="String should have at least 1 character"): - unwrap(AgentAppApi.put)(AgentAppApi(), "tenant-1", agent_id) + unwrap(AgentAppApi.put)(AgentAppApi(), "tenant-1", SimpleNamespace(id="account-1"), agent_id) def test_invite_options_get_parses_app_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: @@ -1043,7 +1068,7 @@ def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id( app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str ) -> None: agent_id = "00000000-0000-0000-0000-000000000001" - app_model = SimpleNamespace(id="app-1", mode="agent") + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="agent") captured: dict[str, object] = {} def resolve_agent_app_model(**kwargs: object) -> object: @@ -1082,7 +1107,7 @@ def test_agent_chat_generate_and_stop_routes_resolve_app_from_agent_id( def test_agent_chat_helper_forces_agent_streaming_and_external_trace( app: Flask, monkeypatch: pytest.MonkeyPatch, account_id: str ) -> None: - app_model = SimpleNamespace(id="app-1", mode="agent") + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="agent") current_user = SimpleNamespace(id=account_id) captured: dict[str, object] = {} @@ -1091,6 +1116,11 @@ def test_agent_chat_helper_forces_agent_streaming_and_external_trace( return {"answer": "ok"} monkeypatch.setattr(completion_controller.AppGenerateService, "generate", generate) + monkeypatch.setattr( + completion_controller, + "_resolve_current_user_agent_debug_conversation_id", + lambda **kwargs: "debug-conversation-1", + ) monkeypatch.setattr( completion_controller.helper, "compact_generate_response", @@ -1109,10 +1139,83 @@ def test_agent_chat_helper_forces_agent_streaming_and_external_trace( assert captured["streaming"] is True args = cast(dict[str, object], captured["args"]) assert args["response_mode"] == "streaming" + assert args["conversation_id"] == "debug-conversation-1" assert args["auto_generate_name"] is False assert args["external_trace_id"] == "trace-1" +def test_agent_chat_helper_rejects_foreign_debug_conversation( + app: Flask, + monkeypatch: pytest.MonkeyPatch, + account_id: str, +) -> None: + app_model = SimpleNamespace(id="app-1", tenant_id="tenant-1", mode="agent") + + monkeypatch.setattr( + completion_controller, + "_resolve_current_user_agent_debug_conversation_id", + lambda **kwargs: "owned-conversation", + ) + + with app.test_request_context( + json={ + "inputs": {}, + "query": "hello", + "response_mode": "streaming", + "conversation_id": "00000000-0000-0000-0000-000000000001", + } + ): + with pytest.raises(NotFound): + completion_controller._create_chat_message( + current_tenant_id="tenant-1", + current_user=SimpleNamespace(id=account_id), + app_model=app_model, + agent_id="agent-1", + ) + + +def test_resolve_current_user_agent_debug_conversation_uses_agent_or_backing_app( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[dict[str, object]] = [] + + class FakeRosterService: + def __init__(self, session: object) -> None: + calls.append({"session": session}) + + def get_or_create_agent_app_debug_conversation_id(self, **kwargs: object) -> str: + calls.append({"get_or_create": kwargs}) + return f"debug-{kwargs['agent_id']}" + + def get_app_backing_agent(self, **kwargs: object) -> object: + calls.append({"get_app_backing_agent": kwargs}) + return SimpleNamespace(id="backing-agent") + + monkeypatch.setattr(completion_controller, "AgentRosterService", FakeRosterService) + monkeypatch.setattr(completion_controller, "db", SimpleNamespace(session="session-1")) + + explicit_id = completion_controller._resolve_current_user_agent_debug_conversation_id( + current_tenant_id="tenant-1", + current_user=SimpleNamespace(id="account-1"), + app_model=SimpleNamespace(id="app-1"), + agent_id="agent-1", + ) + fallback_id = completion_controller._resolve_current_user_agent_debug_conversation_id( + current_tenant_id="tenant-1", + current_user=SimpleNamespace(id="account-1"), + app_model=SimpleNamespace(id="app-1"), + agent_id=None, + ) + + 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[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"} + } + + @pytest.mark.parametrize( ("error", "expected"), [ @@ -1159,7 +1262,7 @@ def test_agent_chat_helper_maps_generation_errors( def test_agent_chat_message_routes_resolve_app_from_agent_id(app: Flask, monkeypatch: pytest.MonkeyPatch) -> None: agent_id = "00000000-0000-0000-0000-000000000001" message_id = "00000000-0000-0000-0000-000000000002" - app_model = SimpleNamespace(id="app-1") + app_model = SimpleNamespace(id="app-1", mode="agent") current_user = SimpleNamespace(id="account-1") captured: dict[str, object] = {} @@ -1189,7 +1292,9 @@ def test_agent_chat_message_routes_resolve_app_from_agent_id(app: Flask, monkeyp monkeypatch.setattr(message_controller, "_get_message_suggested_questions", get_message_suggested_questions) monkeypatch.setattr(message_controller, "_get_message_detail", get_message_detail) - assert unwrap(AgentChatMessageListApi.get)(AgentChatMessageListApi(), "tenant-1", agent_id) == {"data": []} + assert unwrap(AgentChatMessageListApi.get)(AgentChatMessageListApi(), "tenant-1", current_user, agent_id) == { + "data": [] + } assert cast(dict[str, object], captured["list"])["app_model"] is app_model with app.test_request_context(json={"message_id": message_id, "rating": "like"}): @@ -1246,11 +1351,73 @@ def test_list_chat_messages_supports_first_id_pagination(app: Flask, monkeypatch "/console/api/agent/agent-1/chat-messages" f"?conversation_id={conversation_id}&first_id={first_message_id}&limit=1" ): - result = message_controller._list_chat_messages(app_model=SimpleNamespace(id="app-1")) + result = message_controller._list_chat_messages(app_model=SimpleNamespace(id="app-1", mode="chat")) assert result == {"data": [older_message_id], "limit": 1, "has_more": True} +def test_list_agent_chat_messages_uses_current_user_conversation( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + conversation_id = "00000000-0000-0000-0000-000000000010" + message_id = "00000000-0000-0000-0000-000000000011" + conversation = SimpleNamespace(id=conversation_id) + message = SimpleNamespace(id=message_id, created_at=1) + current_user = SimpleNamespace(id="account-1") + app_model = SimpleNamespace(id="app-1", mode="agent") + captured: dict[str, object] = {} + session = SimpleNamespace( + scalar=lambda _stmt: False, + scalars=lambda _stmt: SimpleNamespace(all=lambda: [message]), + ) + + class FakeMessagePaginationResponse: + @classmethod + def model_validate(cls, pagination: object, from_attributes: bool = False) -> object: + return SimpleNamespace( + model_dump=lambda mode: { + "data": [item.id for item in pagination.data], + "limit": pagination.limit, + "has_more": pagination.has_more, + } + ) + + def get_conversation(**kwargs: object) -> object: + captured.update(kwargs) + return conversation + + monkeypatch.setattr(message_controller.ConversationService, "get_conversation", get_conversation) + monkeypatch.setattr(message_controller, "db", SimpleNamespace(session=session)) + monkeypatch.setattr(message_controller, "attach_message_extra_contents", lambda messages: None) + monkeypatch.setattr(message_controller, "MessageInfiniteScrollPaginationResponse", FakeMessagePaginationResponse) + + with app.test_request_context(f"/console/api/agent/agent-1/chat-messages?conversation_id={conversation_id}"): + result = message_controller._list_chat_messages(app_model=app_model, current_user=current_user) + + assert result == {"data": [message_id], "limit": 20, "has_more": False} + assert captured == {"app_model": app_model, "conversation_id": conversation_id, "user": current_user} + + +def test_list_agent_chat_messages_rejects_foreign_conversation( + app: Flask, + monkeypatch: pytest.MonkeyPatch, +) -> None: + conversation_id = "00000000-0000-0000-0000-000000000010" + monkeypatch.setattr( + message_controller.ConversationService, + "get_conversation", + lambda **kwargs: (_ for _ in ()).throw(message_controller.ConversationNotExistsError()), + ) + + with app.test_request_context(f"/console/api/agent/agent-1/chat-messages?conversation_id={conversation_id}"): + with pytest.raises(NotFound): + message_controller._list_chat_messages( + app_model=SimpleNamespace(id="app-1", mode="agent"), + current_user=SimpleNamespace(id="account-1"), + ) + + def test_update_message_feedback_rejects_empty_rating_without_existing_feedback( app: Flask, monkeypatch: pytest.MonkeyPatch ) -> None: 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 bad6e0f3151..52ff00c0855 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -8,6 +8,7 @@ from models.agent import ( Agent, AgentConfigRevisionOperation, AgentConfigSnapshot, + AgentDebugConversation, AgentKind, AgentScope, AgentSource, @@ -1093,6 +1094,11 @@ def test_roster_create_detail_and_lookup_helpers(monkeypatch: pytest.MonkeyPatch scalars=[[AgentConfigSnapshot(id="version-1", agent_id="agent-1", version=1)]], ) service = AgentRosterService(fake_session) + monkeypatch.setattr( + AgentRosterService, + "_get_or_create_agent_app_debug_conversation", + lambda self, *, agent, account_id: "debug-conversation-1", + ) payload = roster_service.RosterAgentCreatePayload( name="Analyst", description="desc", @@ -1134,6 +1140,135 @@ def test_roster_create_detail_and_lookup_helpers(monkeypatch: pytest.MonkeyPatch assert loaded_versions["version-1"].agent_id == "agent-1" +def test_agent_app_debug_conversation_create_reuse_and_recreate(): + 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, + ) + + create_session = FakeSession(scalar=[agent, None]) + created_id = AgentRosterService(create_session).get_or_create_agent_app_debug_conversation_id( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + ) + created_conversation = next(value for value in create_session.added if isinstance(value, Conversation)) + created_mapping = next(value for value in create_session.added if isinstance(value, AgentDebugConversation)) + 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 create_session.commits == 1 + + existing_mapping = AgentDebugConversation( + tenant_id="tenant-1", + agent_id="agent-1", + app_id="app-1", + account_id="account-1", + conversation_id="existing-conversation", + ) + reuse_session = FakeSession(scalar=[agent, existing_mapping, "existing-conversation"]) + reused_id = AgentRosterService(reuse_session).get_or_create_agent_app_debug_conversation_id( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + ) + assert reused_id == "existing-conversation" + assert reuse_session.added == [] + assert reuse_session.commits == 1 + + stale_mapping = AgentDebugConversation( + tenant_id="tenant-1", + agent_id="agent-1", + app_id="app-1", + account_id="account-1", + conversation_id="deleted-conversation", + ) + recreate_session = FakeSession(scalar=[agent, stale_mapping, None]) + recreated_id = AgentRosterService(recreate_session).get_or_create_agent_app_debug_conversation_id( + tenant_id="tenant-1", + agent_id="agent-1", + account_id="account-1", + ) + assert recreated_id == stale_mapping.conversation_id + assert recreated_id != "deleted-conversation" + assert any(isinstance(value, Conversation) for value in recreate_session.added) + assert recreate_session.commits == 1 + + +def test_agent_app_debug_conversation_requires_app_binding(): + 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(FakeSession())._get_or_create_agent_app_debug_conversation( + agent=agent, + account_id="account-1", + ) + + +def test_load_or_create_agent_app_debug_conversations_filters_agent_apps(): + 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, + ) + + fake_session = FakeSession(scalar=[None]) + result = AgentRosterService(fake_session).load_or_create_agent_app_debug_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"] + assert result["agent-1"] + assert fake_session.commits == 1 + assert len([value for value in fake_session.added if isinstance(value, AgentDebugConversation)]) == 1 + + def test_agent_app_visible_versions_exclude_draft_saves(): agent_app = Agent(source=AgentSource.AGENT_APP) roster_agent = Agent(source=AgentSource.ROSTER) @@ -1448,7 +1583,6 @@ class TestAgentAppBackingAgent: assert agent.agent_kind == AgentKind.DIFY_AGENT assert agent.name == "Iris" assert agent.role == "research assistant" - assert agent.debug_conversation_id is not None # A v1 snapshot + revision are seeded and wired as the active version. snapshots = [a for a in session.added if isinstance(a, AgentConfigSnapshot)] assert len(snapshots) == 1 @@ -1460,12 +1594,18 @@ class TestAgentAppBackingAgent: assert len(revisions) == 1 conversations = [a for a in session.added if isinstance(a, Conversation)] assert len(conversations) == 1 - assert agent.debug_conversation_id == conversations[0].id 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 = [a for a in session.added if isinstance(a, AgentDebugConversation)] + 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 session.commits == 0