fix: isolate agent debug conversations by account (#37766)

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
zyssyz123 2026-06-23 10:04:23 +08:00 committed by GitHub
parent ab11083c2d
commit b67a04aa22
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 583 additions and 51 deletions

View File

@ -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/<uuid:agent_id>")
@ -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/<uuid:agent_id>/api-access")

View File

@ -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/<uuid:agent_id>/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/<uuid:app_id>/chat-messages/<string:task_id>/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,

View File

@ -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/<uuid:agent_id>/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/<uuid:app_id>/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.")

View File

@ -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")

View File

@ -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",

View File

@ -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.

View File

@ -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:

View File

@ -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:

View File

@ -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