From 0df30dd2691c36dbfc827890cd601ac5025fa9a5 Mon Sep 17 00:00:00 2001 From: zyssyz123 <916125788@qq.com> Date: Thu, 18 Jun 2026 20:06:27 +0800 Subject: [PATCH] fix(agent): resolve roster file downloads versions and log filters (#37626) Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- api/controllers/console/agent/roster.py | 75 +++++++++-- api/fields/agent_fields.py | 6 + api/openapi/markdown/console-openapi.md | 28 ++++- api/services/agent/observability_service.py | 118 +++++++++++------- api/services/agent/roster_service.py | 61 ++++++--- api/services/agent_drive_service.py | 28 ++++- .../console/agent/test_agent_controllers.py | 12 +- .../agent/test_agent_observability_service.py | 50 +++++++- .../services/agent/test_agent_services.py | 26 +++- .../services/test_agent_drive_service.py | 15 +++ .../generated/api/console/agent/types.gen.ts | 12 ++ .../generated/api/console/agent/zod.gen.ts | 12 ++ .../generated/api/console/apps/types.gen.ts | 2 + .../generated/api/console/apps/zod.gen.ts | 2 + 14 files changed, 356 insertions(+), 91 deletions(-) diff --git a/api/controllers/console/agent/roster.py b/api/controllers/console/agent/roster.py index 462f039edfe..d4546ac88bf 100644 --- a/api/controllers/console/agent/roster.py +++ b/api/controllers/console/agent/roster.py @@ -114,11 +114,21 @@ class AgentLogsQuery(BaseModel): page: int = Field(default=1, ge=1, description="Page number") limit: int = Field(default=20, ge=1, le=100, description="Page size") keyword: str | None = Field(default=None, description="Search query, answer, or conversation name") - status: str | None = Field(default=None, description="Filter by success, failed, or paused") + status: str | None = Field(default=None, description="Deprecated single status filter") + statuses: list[str] = Field(default_factory=list, description="Filter by one or more of success, failed, paused") source: str | None = Field( default=None, - description="Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger", + description="Deprecated single source filter", ) + sources: list[str] = Field( + default_factory=list, + description=( + "Filter by one or more source IDs, e.g. webapp: " + "or workflow::::" + ), + ) + sort_by: str = Field(default="updated_at", description="Sort by created_at or updated_at") + sort_order: str = Field(default="desc", description="Sort order: asc or desc") start: str | None = Field(default=None, description="Start date (YYYY-MM-DD HH:MM)") end: str | None = Field(default=None, description="End date (YYYY-MM-DD HH:MM)") @@ -129,6 +139,33 @@ class AgentLogsQuery(BaseModel): return None return value + @field_validator("statuses", "sources", mode="before") + @classmethod + def empty_list_values_to_list(cls, value: object) -> list[str]: + if value in (None, ""): + return [] + if isinstance(value, str): + return [value] + if isinstance(value, list): + return [item for item in value if item] + return [] + + @field_validator("sort_by") + @classmethod + def validate_sort_by(cls, value: str) -> str: + normalized = value.strip().lower() + if normalized not in {"created_at", "updated_at"}: + raise ValueError("sort_by must be created_at or updated_at") + return normalized + + @field_validator("sort_order") + @classmethod + def validate_sort_order(cls, value: str) -> str: + normalized = value.strip().lower() + if normalized not in {"asc", "desc"}: + raise ValueError("sort_order must be asc or desc") + return normalized + class AgentStatisticsQuery(BaseModel): source: str | None = Field( @@ -298,6 +335,18 @@ def _parse_observability_time_range(start: str | None, end: str | None, account: abort(400, description=str(exc)) +def _multi_query_values(name: str, legacy_name: str | None = None) -> list[str]: + values: list[str] = [] + for query_name in (name, f"{name}[]"): + values.extend(request.args.getlist(query_name)) + if legacy_name: + values.extend(request.args.getlist(legacy_name)) + parsed: list[str] = [] + for value in values: + parsed.extend(item.strip() for item in value.split(",") if item.strip()) + return parsed + + @console_ns.route("/agent") class AgentAppListApi(Resource): @console_ns.doc(params=query_params_from_model(AppListQuery)) @@ -463,7 +512,10 @@ class AgentLogsApi(Resource): @with_current_tenant_id 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) - query = AgentLogsQuery.model_validate(request.args.to_dict(flat=True)) + query_data: dict[str, object] = dict(request.args.to_dict(flat=True)) + query_data["sources"] = _multi_query_values("sources", "source") + query_data["statuses"] = _multi_query_values("statuses", "status") + query = AgentLogsQuery.model_validate(query_data) start, end = _parse_observability_time_range(query.start, query.end, current_user) try: payload = _agent_observability_service().list_logs( @@ -473,8 +525,10 @@ class AgentLogsApi(Resource): page=query.page, limit=query.limit, keyword=query.keyword, - status=query.status, - source=query.source, + statuses=tuple(query.statuses), + sources=tuple(query.sources), + sort_by=query.sort_by, + sort_order=query.sort_order, start=start, end=end, ), @@ -495,7 +549,10 @@ class AgentLogMessagesApi(Resource): @with_current_tenant_id def get(self, tenant_id: str, current_user: Account, agent_id: UUID, conversation_id: UUID): app_model = _resolve_agent_app_model(tenant_id=tenant_id, agent_id=agent_id) - query = AgentLogsQuery.model_validate(request.args.to_dict(flat=True)) + query_data: dict[str, object] = dict(request.args.to_dict(flat=True)) + query_data["sources"] = _multi_query_values("sources", "source") + query_data["statuses"] = _multi_query_values("statuses", "status") + query = AgentLogsQuery.model_validate(query_data) start, end = _parse_observability_time_range(query.start, query.end, current_user) try: payload = _agent_observability_service().list_log_messages( @@ -506,8 +563,10 @@ class AgentLogMessagesApi(Resource): page=query.page, limit=query.limit, keyword=query.keyword, - status=query.status, - source=query.source, + statuses=tuple(query.statuses), + sources=tuple(query.sources), + sort_by=query.sort_by, + sort_order=query.sort_order, start=start, end=end, ), diff --git a/api/fields/agent_fields.py b/api/fields/agent_fields.py index 1dc83ab3aff..ec64395d6fd 100644 --- a/api/fields/agent_fields.py +++ b/api/fields/agent_fields.py @@ -36,7 +36,13 @@ from services.entities.agent_entities import ( class AgentConfigSnapshotSummaryResponse(ResponseModel): id: str agent_id: str | None = None + # User-facing version number among visible published versions. version: int + # Alias for the user-facing version number; kept explicit for clients that + # want to distinguish it from the immutable snapshot sequence. + display_version: int | None = None + # Immutable snapshot sequence number used internally for audit/history. + snapshot_version: int | None = None summary: str | None = None version_note: str | None = None created_by: str | None = None diff --git a/api/openapi/markdown/console-openapi.md b/api/openapi/markdown/console-openapi.md index 22177033426..32bc41e945d 100644 --- a/api/openapi/markdown/console-openapi.md +++ b/api/openapi/markdown/console-openapi.md @@ -680,9 +680,13 @@ Commit an uploaded file into the Agent App drive under files/ | keyword | query | Search query, answer, or conversation name | No | string | | limit | query | Page size | No | integer,
**Default:** 20 | | page | query | Page number | No | integer,
**Default:** 1 | -| source | query | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No | string | +| sort_by | query | Sort by created_at or updated_at | No | string,
**Default:** updated_at | +| sort_order | query | Sort order: asc or desc | No | string,
**Default:** desc | +| source | query | Deprecated single source filter | No | string | +| sources | query | Filter by one or more source IDs, e.g. webapp: or workflow:::: | No | [ string ] | | start | query | Start date (YYYY-MM-DD HH:MM) | No | string | -| status | query | Filter by success, failed, or paused | No | string | +| status | query | Deprecated single status filter | No | string | +| statuses | query | Filter by one or more of success, failed, paused | No | [ string ] | | agent_id | path | | Yes | string (uuid) | #### Responses @@ -700,9 +704,13 @@ Commit an uploaded file into the Agent App drive under files/ | keyword | query | Search query, answer, or conversation name | No | string | | limit | query | Page size | No | integer,
**Default:** 20 | | page | query | Page number | No | integer,
**Default:** 1 | -| source | query | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No | string | +| sort_by | query | Sort by created_at or updated_at | No | string,
**Default:** updated_at | +| sort_order | query | Sort order: asc or desc | No | string,
**Default:** desc | +| source | query | Deprecated single source filter | No | string | +| sources | query | Filter by one or more source IDs, e.g. webapp: or workflow:::: | No | [ string ] | | start | query | Start date (YYYY-MM-DD HH:MM) | No | string | -| status | query | Filter by success, failed, or paused | No | string | +| status | query | Deprecated single status filter | No | string | +| statuses | query | Filter by one or more of success, failed, paused | No | [ string ] | | agent_id | path | | Yes | string (uuid) | | conversation_id | path | | Yes | string (uuid) | @@ -11702,8 +11710,10 @@ Audit operation recorded for Agent Soul version/revision changes. | config_snapshot | [AgentSoulConfig](#agentsoulconfig) | | Yes | | created_at | integer | | No | | created_by | string | | No | +| display_version | integer | | No | | id | string | | Yes | | revisions | [ [AgentConfigRevisionResponse](#agentconfigrevisionresponse) ] | | No | +| snapshot_version | integer | | No | | summary | string | | No | | version | integer | | Yes | | version_note | string | | No | @@ -11721,7 +11731,9 @@ Audit operation recorded for Agent Soul version/revision changes. | agent_id | string | | No | | created_at | integer | | No | | created_by | string | | No | +| display_version | integer | | No | | id | string | | Yes | +| snapshot_version | integer | | No | | summary | string | | No | | version | integer | | Yes | | version_note | string | | No | @@ -12110,9 +12122,13 @@ the current roster/workflow APIs scoped to Dify Agent. | keyword | string | Search query, answer, or conversation name | No | | limit | integer,
**Default:** 20 | Page size | No | | page | integer,
**Default:** 1 | Page number | No | -| source | string | Filter by all, console/explore, api/service-api, web-app, debugger, openapi, or trigger | No | +| sort_by | string,
**Default:** updated_at | Sort by created_at or updated_at | No | +| sort_order | string,
**Default:** desc | Sort order: asc or desc | No | +| source | string | Deprecated single source filter | No | +| sources | [ string ] | Filter by one or more source IDs, e.g. webapp: or workflow:::: | No | | start | string | Start date (YYYY-MM-DD HH:MM) | No | -| status | string | Filter by success, failed, or paused | No | +| status | string | Deprecated single status filter | No | +| statuses | [ string ] | Filter by one or more of success, failed, paused | No | #### AgentMemoryArtifactConfig diff --git a/api/services/agent/observability_service.py b/api/services/agent/observability_service.py index 18c7733651e..76b36abed2a 100644 --- a/api/services/agent/observability_service.py +++ b/api/services/agent/observability_service.py @@ -22,8 +22,10 @@ class AgentLogQueryParams: page: int = 1 limit: int = 20 keyword: str | None = None - status: str | None = None - source: str | None = None + statuses: tuple[str, ...] = () + sources: tuple[str, ...] = () + sort_by: str = "updated_at" + sort_order: str = "desc" start: datetime | None = None end: datetime | None = None @@ -104,6 +106,18 @@ class AgentObservabilityService: ) return AgentSourceFilter(kind="webapp", invoke_from=cls.resolve_source(source)) + @classmethod + def resolve_source_filters(cls, sources: tuple[str, ...]) -> list[AgentSourceFilter]: + if not sources: + return [AgentSourceFilter(kind="all")] + filters: list[AgentSourceFilter] = [] + for source in sources: + source_filter = cls.resolve_source_filter(source) + if source_filter.kind == "all": + return [source_filter] + filters.append(source_filter) + return filters + @staticmethod def _message_status(message: Message) -> str: if message.error or message.status == MessageStatus.ERROR: @@ -143,20 +157,24 @@ class AgentObservabilityService: } def list_logs(self, *, app: App, agent_id: str, params: AgentLogQueryParams) -> dict[str, Any]: - source_filter = self.resolve_source_filter(params.source) + source_filters = self.resolve_source_filters(params.sources) rows: list[dict[str, Any]] = [] - if source_filter.kind in {"all", "webapp"}: - rows.extend(self._list_webapp_conversation_logs(app=app, params=params, source_filter=source_filter)) - if source_filter.kind in {"all", "workflow"}: - rows.extend( - self._list_workflow_conversation_logs( - app=app, - agent_id=agent_id, - params=params, - source_filter=source_filter, + for source_filter in source_filters: + if source_filter.kind in {"all", "webapp"}: + rows.extend(self._list_webapp_conversation_logs(app=app, params=params, source_filter=source_filter)) + if source_filter.kind in {"all", "workflow"}: + rows.extend( + self._list_workflow_conversation_logs( + app=app, + agent_id=agent_id, + params=params, + source_filter=source_filter, + ) ) - ) - rows.sort(key=lambda row: (row["updated_at"] or 0, row["id"]), reverse=True) + rows_by_scope = {(row["id"], row["source"]["id"] if row.get("source") else ""): row for row in rows} + rows = list(rows_by_scope.values()) + sort_by = "created_at" if params.sort_by == "created_at" else "updated_at" + rows.sort(key=lambda row: (row[sort_by] or 0, row["id"]), reverse=params.sort_order != "asc") total = len(rows) start = (params.page - 1) * params.limit @@ -172,30 +190,36 @@ class AgentObservabilityService: def list_log_messages( self, *, app: App, agent_id: str, conversation_id: str, params: AgentLogQueryParams ) -> dict[str, Any]: - source_filter = self.resolve_source_filter(params.source) + source_filters = self.resolve_source_filters(params.sources) rows: list[Message] = [] - if source_filter.kind in {"all", "webapp"}: - rows.extend( - self._list_webapp_messages( - app=app, - conversation_id=conversation_id, - params=params, - source_filter=source_filter, + for source_filter in source_filters: + if source_filter.kind in {"all", "webapp"}: + rows.extend( + self._list_webapp_messages( + app=app, + conversation_id=conversation_id, + params=params, + source_filter=source_filter, + ) ) - ) - if source_filter.kind in {"all", "workflow"}: - rows.extend( - self._list_workflow_messages( - app=app, - agent_id=agent_id, - conversation_id=conversation_id, - params=params, - source_filter=source_filter, + if source_filter.kind in {"all", "workflow"}: + rows.extend( + self._list_workflow_messages( + app=app, + agent_id=agent_id, + conversation_id=conversation_id, + params=params, + source_filter=source_filter, + ) ) - ) deduped = {message.id: message for message in rows} - sorted_rows = sorted(deduped.values(), key=lambda message: (message.created_at, message.id), reverse=True) + sort_column = Message.created_at if params.sort_by == "created_at" else Message.updated_at + sorted_rows = sorted( + deduped.values(), + key=lambda message: (getattr(message, sort_column.key), message.id), + reverse=params.sort_order != "asc", + ) total = len(sorted_rows) start = (params.page - 1) * params.limit end = start + params.limit @@ -421,8 +445,8 @@ class AgentObservabilityService: Message.answer.ilike(pattern, escape="\\"), ) ) - if params.status: - stmt = cls._apply_status_filter(stmt, params.status) + if params.statuses: + stmt = cls._apply_status_filter(stmt, params.statuses) return stmt @staticmethod @@ -444,15 +468,21 @@ class AgentObservabilityService: return stmt.where(Message.invoke_from == source) @staticmethod - def _apply_status_filter(stmt, status: str): - normalized = status.strip().lower() - if normalized in {"success", "normal"}: - return stmt.where(Message.error.is_(None), Message.status == MessageStatus.NORMAL) - if normalized in {"failed", "error"}: - return stmt.where(or_(Message.error.is_not(None), Message.status == MessageStatus.ERROR)) - if normalized == "paused": - return stmt.where(Message.status == MessageStatus.PAUSED) - raise ValueError(f"Unsupported status: {status}") + def _apply_status_filter(stmt, statuses: tuple[str, ...]): + conditions = [] + for status in statuses: + normalized = status.strip().lower() + if normalized in {"success", "normal"}: + conditions.append(and_(Message.error.is_(None), Message.status == MessageStatus.NORMAL)) + elif normalized in {"failed", "error"}: + conditions.append(or_(Message.error.is_not(None), Message.status == MessageStatus.ERROR)) + elif normalized == "paused": + conditions.append(Message.status == MessageStatus.PAUSED) + else: + raise ValueError(f"Unsupported status: {status}") + if not conditions: + return stmt + return stmt.where(or_(*conditions)) @classmethod def _serialize_conversation_log( diff --git a/api/services/agent/roster_service.py b/api/services/agent/roster_service.py index 8e68174b35c..ca8428b4f7c 100644 --- a/api/services/agent/roster_service.py +++ b/api/services/agent/roster_service.py @@ -121,12 +121,27 @@ class AgentRosterService: "id": version.id, "agent_id": version.agent_id, "version": version.version, + "display_version": version.version, + "snapshot_version": version.version, "summary": version.summary, "version_note": version.version_note, "created_by": version.created_by, "created_at": to_timestamp(version.created_at), } + @classmethod + def _serialize_visible_version( + cls, + version: AgentConfigSnapshot, + *, + display_version: int, + ) -> dict[str, Any]: + payload = cls.serialize_version(version) or {} + payload["version"] = display_version + payload["display_version"] = display_version + payload["snapshot_version"] = version.version + return payload + @staticmethod def _build_roster_agents_stmt(*, tenant_id: str, keyword: str | None = None): stmt = select(Agent).where( @@ -673,15 +688,7 @@ class AgentRosterService: def list_agent_versions(self, *, tenant_id: str, agent_id: str) -> list[dict[str, Any]]: agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) - visible_version_ids = ( - select(AgentConfigRevision.current_snapshot_id) - .where( - AgentConfigRevision.tenant_id == tenant_id, - AgentConfigRevision.agent_id == agent_id, - AgentConfigRevision.operation.in_(self._visible_version_operations(agent)), - ) - .subquery() - ) + visible_version_ids = self._visible_version_ids_stmt(tenant_id=tenant_id, agent_id=agent_id, agent=agent) versions = list( self._session.scalars( select(AgentConfigSnapshot) @@ -693,25 +700,39 @@ class AgentRosterService: .order_by(AgentConfigSnapshot.version.desc()) ).all() ) + total = len(versions) return [ - serialized_version - for version in versions - if (serialized_version := self.serialize_version(version)) is not None + self._serialize_visible_version(version, display_version=total - index) + for index, version in enumerate(versions) ] - def get_agent_version_detail(self, *, tenant_id: str, agent_id: str, version_id: str) -> dict[str, Any]: - agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) - visible_revision_id = self._session.scalar( - select(AgentConfigRevision.id) + def _visible_version_ids_stmt(self, *, tenant_id: str, agent_id: str, agent: Agent): + return ( + select(AgentConfigRevision.current_snapshot_id) .where( AgentConfigRevision.tenant_id == tenant_id, AgentConfigRevision.agent_id == agent_id, - AgentConfigRevision.current_snapshot_id == version_id, AgentConfigRevision.operation.in_(self._visible_version_operations(agent)), ) - .limit(1) + .subquery() ) - if not visible_revision_id: + + def get_agent_version_detail(self, *, tenant_id: str, agent_id: str, version_id: str) -> dict[str, Any]: + agent = self._get_agent(tenant_id=tenant_id, agent_id=agent_id, roster_only=True) + visible_version_ids = self._visible_version_ids_stmt(tenant_id=tenant_id, agent_id=agent_id, agent=agent) + visible_versions = list( + self._session.scalars( + select(AgentConfigSnapshot) + .where( + AgentConfigSnapshot.tenant_id == tenant_id, + AgentConfigSnapshot.agent_id == agent_id, + AgentConfigSnapshot.id.in_(select(visible_version_ids.c.current_snapshot_id)), + ) + .order_by(AgentConfigSnapshot.version.asc()) + ).all() + ) + display_versions_by_id = {version.id: index for index, version in enumerate(visible_versions, start=1)} + if version_id not in display_versions_by_id: raise AgentVersionNotFoundError() version = self._get_version(tenant_id=tenant_id, agent_id=agent_id, version_id=version_id) revisions = list( @@ -725,7 +746,7 @@ class AgentRosterService: .order_by(AgentConfigRevision.revision.desc()) ).all() ) - result = self.serialize_version(version) or {} + result = self._serialize_visible_version(version, display_version=display_versions_by_id[version_id]) result["config_snapshot"] = version.config_snapshot_dict result["revisions"] = [ { diff --git a/api/services/agent_drive_service.py b/api/services/agent_drive_service.py index 276f6339b8f..bb3f8ca69e3 100644 --- a/api/services/agent_drive_service.py +++ b/api/services/agent_drive_service.py @@ -19,6 +19,7 @@ from __future__ import annotations import logging import re +import urllib.parse from typing import Any, Literal from pydantic import BaseModel @@ -386,7 +387,12 @@ class AgentDriveService: @staticmethod def _resolve_download_url( - *, tenant_id: str, file_kind: AgentDriveFileKind, file_id: str, for_external: bool = False + *, + tenant_id: str, + file_kind: AgentDriveFileKind, + file_id: str, + for_external: bool = False, + as_attachment: bool = False, ) -> str | None: """Signed URL for a drive value. ``for_external`` selects the audience: the inner manifest hands agents *internal* URLs, while the console @@ -398,10 +404,22 @@ class AgentDriveService: controller = DatabaseFileAccessController() runtime = DifyWorkflowFileRuntime(file_access_controller=controller) try: + if file_kind == AgentDriveFileKind.UPLOAD_FILE: + return runtime.resolve_upload_file_url( + upload_file_id=file_id, + for_external=for_external, + as_attachment=as_attachment, + ) # No FileAccessScope bound -> drive-owned: the builders still filter by # tenant_id, so resolution is tenant-scoped without user-level checks. file = file_factory.build_from_mapping(mapping=mapping, tenant_id=tenant_id, access_controller=controller) - return runtime.resolve_file_url(file=file, for_external=for_external) + url = runtime.resolve_file_url(file=file, for_external=for_external) + if as_attachment and url: + parsed = urllib.parse.urlsplit(url) + query = urllib.parse.parse_qsl(parsed.query, keep_blank_values=True) + query.append(("as_attachment", "true")) + return urllib.parse.urlunsplit(parsed._replace(query=urllib.parse.urlencode(query))) + return url except ValueError: return None @@ -475,7 +493,11 @@ class AgentDriveService: self._assert_agent_belongs_to_tenant(session, tenant_id=tenant_id, agent_id=agent_id) row = self._require_row(session, tenant_id=tenant_id, agent_id=agent_id, key=key) url = self._resolve_download_url( - tenant_id=tenant_id, file_kind=row.file_kind, file_id=row.file_id, for_external=True + tenant_id=tenant_id, + file_kind=row.file_kind, + file_id=row.file_id, + for_external=True, + as_attachment=True, ) if url is None: raise AgentDriveError("drive_key_not_found", "drive value cannot be resolved", status_code=404) 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 25c31453f42..8b77772a36d 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 @@ -658,7 +658,8 @@ def test_agent_observability_routes_resolve_app_from_agent_id( account = SimpleNamespace(id=account_id, timezone="UTC") with app.test_request_context( "/console/api/agent/00000000-0000-0000-0000-000000000001/logs" - "?page=2&limit=5&keyword=hello&status=success&source=console" + "?page=2&limit=5&keyword=hello&statuses[]=success&statuses[]=failed&sources[]=webapp:app-1" + "&sources[]=workflow:app-2:workflow-1:v1:node-1&sort_by=created_at&sort_order=asc" ): logs = unwrap(AgentLogsApi.get)(AgentLogsApi(), "tenant-1", account, agent_id) @@ -671,8 +672,10 @@ def test_agent_observability_routes_resolve_app_from_agent_id( assert logs_params.page == 2 assert logs_params.limit == 5 assert logs_params.keyword == "hello" - assert logs_params.status == "success" - assert logs_params.source == "console" + assert logs_params.statuses == ("success", "failed") + assert logs_params.sources == ("webapp:app-1", "workflow:app-2:workflow-1:v1:node-1") + assert logs_params.sort_by == "created_at" + assert logs_params.sort_order == "asc" with app.test_request_context( "/console/api/agent/00000000-0000-0000-0000-000000000001/logs/00000000-0000-0000-0000-000000000002/messages" @@ -690,6 +693,9 @@ def test_agent_observability_routes_resolve_app_from_agent_id( assert messages_call["app"] is app_model assert messages_call["agent_id"] == agent_id assert messages_call["conversation_id"] == "00000000-0000-0000-0000-000000000002" + messages_params = cast(Any, messages_call["params"]) + assert messages_params.sources == () + assert messages_params.statuses == () with app.test_request_context("/console/api/agent/00000000-0000-0000-0000-000000000001/log-sources"): sources = unwrap(AgentLogSourcesApi.get)(AgentLogSourcesApi(), "tenant-1", account, agent_id) diff --git a/api/tests/unit_tests/services/agent/test_agent_observability_service.py b/api/tests/unit_tests/services/agent/test_agent_observability_service.py index ab67b0e38da..f98c548d176 100644 --- a/api/tests/unit_tests/services/agent/test_agent_observability_service.py +++ b/api/tests/unit_tests/services/agent/test_agent_observability_service.py @@ -6,7 +6,7 @@ import pytest from core.app.entities.app_invoke_entities import InvokeFrom from models.enums import ConversationFromSource, MessageStatus -from services.agent.observability_service import AgentObservabilityService +from services.agent.observability_service import AgentLogQueryParams, AgentObservabilityService def test_resolve_source_accepts_frontend_aliases() -> None: @@ -40,6 +40,54 @@ def test_resolve_source_filter_accepts_structured_sources() -> None: AgentObservabilityService.resolve_source_filter("workflow:broken") +def test_resolve_source_filters_accepts_multiple_structured_sources() -> None: + filters = AgentObservabilityService.resolve_source_filters(("webapp:app-1", "workflow:app-2:workflow-1:v1:node-1")) + + assert [source_filter.kind for source_filter in filters] == ["webapp", "workflow"] + assert filters[0].app_id == "app-1" + assert filters[1].node_id == "node-1" + assert AgentObservabilityService.resolve_source_filters(())[0].kind == "all" + assert AgentObservabilityService.resolve_source_filters(("all", "webapp:app-1"))[0].kind == "all" + + +def test_apply_status_filter_accepts_multiple_statuses() -> None: + class FakeStmt: + def __init__(self): + self.conditions = [] + + def where(self, *conditions): + self.conditions.extend(conditions) + return self + + stmt = FakeStmt() + + result = AgentObservabilityService._apply_status_filter(stmt, ("success", "failed", "paused")) + + assert result is stmt + assert len(stmt.conditions) == 1 + with pytest.raises(ValueError, match="Unsupported status"): + AgentObservabilityService._apply_status_filter(FakeStmt(), ("unknown",)) + + +def test_list_logs_sorts_by_requested_field(monkeypatch: pytest.MonkeyPatch) -> None: + service = AgentObservabilityService(session=None) + app = SimpleNamespace(id="app-1") + rows = [ + {"id": "old", "source": {"id": "webapp:app-1"}, "created_at": 10, "updated_at": 100}, + {"id": "new", "source": {"id": "webapp:app-1"}, "created_at": 20, "updated_at": 50}, + ] + monkeypatch.setattr(service, "_list_webapp_conversation_logs", lambda **kwargs: rows) + monkeypatch.setattr(service, "_list_workflow_conversation_logs", lambda **kwargs: []) + + payload = service.list_logs( + app=app, # type: ignore[arg-type] + agent_id="agent-1", + params=AgentLogQueryParams(sources=("webapp:app-1",), sort_by="created_at", sort_order="asc"), + ) + + assert [item["id"] for item in payload["data"]] == ["old", "new"] + + def test_source_serializers_return_structured_frontend_shape() -> None: app = SimpleNamespace( id="app-1", 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 b0a099c9bd8..43fce6a5135 100644 --- a/api/tests/unit_tests/services/agent/test_agent_services.py +++ b/api/tests/unit_tests/services/agent/test_agent_services.py @@ -916,14 +916,16 @@ def test_published_references_include_app_display_fields_and_sort_by_updated_at( def test_roster_update_archive_versions_and_detail(monkeypatch: pytest.MonkeyPatch): - listed_version = AgentConfigSnapshot(id="version-2", agent_id="agent-1", version=2) + listed_version = AgentConfigSnapshot(id="version-4", agent_id="agent-1", version=4) listed_version_created_at = datetime(2026, 1, 5, 3, 4, 5, tzinfo=UTC) listed_version.created_at = listed_version_created_at + older_listed_version = AgentConfigSnapshot(id="version-2", agent_id="agent-1", version=2) + older_listed_version.created_at = datetime(2026, 1, 4, 3, 4, 5, tzinfo=UTC) revision_created_at = datetime(2026, 1, 6, 3, 4, 5, tzinfo=UTC) revision = SimpleNamespace( id="revision-1", previous_snapshot_id=None, - current_snapshot_id="version-1", + current_snapshot_id="version-2", revision=1, operation=AgentConfigRevisionOperation.CREATE_VERSION, summary=None, @@ -931,7 +933,10 @@ def test_roster_update_archive_versions_and_detail(monkeypatch: pytest.MonkeyPat created_by="account-1", created_at=revision_created_at, ) - fake_session = FakeSession(scalar=["visible-revision"], scalars=[[listed_version], [revision]]) + fake_session = FakeSession( + scalar=["visible-revision"], + scalars=[[listed_version, older_listed_version], [older_listed_version, listed_version], [revision]], + ) agent = Agent( id="agent-1", tenant_id="tenant-1", @@ -942,7 +947,7 @@ def test_roster_update_archive_versions_and_detail(monkeypatch: pytest.MonkeyPat source=AgentSource.AGENT_APP, status=AgentStatus.ACTIVE, ) - version = AgentConfigSnapshot(id="version-1", agent_id="agent-1", version=1, config_snapshot='{"prompt":{}}') + version = AgentConfigSnapshot(id="version-2", agent_id="agent-1", version=2, config_snapshot='{"prompt":{}}') version.created_at = datetime(2026, 1, 4, 3, 4, 5, tzinfo=UTC) service = AgentRosterService(fake_session) @@ -962,12 +967,21 @@ def test_roster_update_archive_versions_and_detail(monkeypatch: pytest.MonkeyPat ) service.archive_roster_agent(tenant_id="tenant-1", agent_id="agent-1", account_id="account-1") versions = service.list_agent_versions(tenant_id="tenant-1", agent_id="agent-1") - detail = service.get_agent_version_detail(tenant_id="tenant-1", agent_id="agent-1", version_id="version-1") + detail = service.get_agent_version_detail(tenant_id="tenant-1", agent_id="agent-1", version_id="version-2") assert updated["description"] == "new" assert agent.status == AgentStatus.ARCHIVED - assert versions[0]["id"] == "version-2" + assert versions[0]["id"] == "version-4" + assert versions[0]["version"] == 2 + assert versions[0]["display_version"] == 2 + assert versions[0]["snapshot_version"] == 4 + assert versions[1]["id"] == "version-2" + assert versions[1]["version"] == 1 + assert versions[1]["snapshot_version"] == 2 assert versions[0]["created_at"] == int(listed_version_created_at.timestamp()) + assert detail["version"] == 1 + assert detail["display_version"] == 1 + assert detail["snapshot_version"] == 2 assert detail["config_snapshot"] == {"prompt": {}} assert detail["created_at"] == int(version.created_at.timestamp()) assert detail["revisions"][0]["created_at"] == int(revision_created_at.timestamp()) diff --git a/api/tests/unit_tests/services/test_agent_drive_service.py b/api/tests/unit_tests/services/test_agent_drive_service.py index 9f8170bfd11..09cde917946 100644 --- a/api/tests/unit_tests/services/test_agent_drive_service.py +++ b/api/tests/unit_tests/services/test_agent_drive_service.py @@ -66,6 +66,7 @@ def _tables() -> Generator[None, None, None]: yield with session_factory.create_session() as session: session.execute(delete(AgentDriveFile)) + session.execute(delete(UploadFile)) session.execute(delete(ToolFile)) session.execute(delete(Agent)) session.commit() @@ -493,6 +494,20 @@ def test_download_url_signs_external_audience(): assert url == "https://signed.example/x" # console downloads are for browsers: external signing, never the internal URL assert resolver.call_args.kwargs["for_external"] is True + assert resolver.call_args.kwargs["as_attachment"] is True + + +def test_upload_file_download_url_uses_attachment_filename(): + upload_file_id = _seed_upload_file(name="report.pdf") + _commit_upload("files/report.pdf", upload_file_id) + + with patch("services.agent_drive_service.DifyWorkflowFileRuntime") as runtime_cls: + runtime_cls.return_value.resolve_upload_file_url.return_value = "https://files.example/report.pdf" + url = AgentDriveService().download_url(tenant_id=TENANT, agent_id=AGENT, key="files/report.pdf") + + assert url == "https://files.example/report.pdf" + assert runtime_cls.return_value.resolve_upload_file_url.call_args.kwargs["for_external"] is True + assert runtime_cls.return_value.resolve_upload_file_url.call_args.kwargs["as_attachment"] is True def test_manifest_items_carry_created_at_for_inspector(): diff --git a/packages/contracts/generated/api/console/agent/types.gen.ts b/packages/contracts/generated/api/console/agent/types.gen.ts index 19a818cf9a6..9685823311d 100644 --- a/packages/contracts/generated/api/console/agent/types.gen.ts +++ b/packages/contracts/generated/api/console/agent/types.gen.ts @@ -281,8 +281,10 @@ export type AgentConfigSnapshotDetailResponse = { config_snapshot: AgentSoulConfig created_at?: number | null created_by?: string | null + display_version?: number | null id: string revisions?: Array + snapshot_version?: number | null summary?: string | null version: number version_note?: string | null @@ -414,7 +416,9 @@ export type AgentConfigSnapshotSummaryResponse = { agent_id?: string | null created_at?: number | null created_by?: string | null + display_version?: number | null id: string + snapshot_version?: number | null summary?: string | null version: number version_note?: string | null @@ -1926,9 +1930,13 @@ export type GetAgentByAgentIdLogsData = { keyword?: string limit?: number page?: number + sort_by?: string + sort_order?: string source?: string + sources?: Array start?: string status?: string + statuses?: Array } url: '/agent/{agent_id}/logs' } @@ -1951,9 +1959,13 @@ export type GetAgentByAgentIdLogsByConversationIdMessagesData = { keyword?: string limit?: number page?: number + sort_by?: string + sort_order?: string source?: string + sources?: Array start?: string status?: string + statuses?: Array } url: '/agent/{agent_id}/logs/{conversation_id}/messages' } diff --git a/packages/contracts/generated/api/console/agent/zod.gen.ts b/packages/contracts/generated/api/console/agent/zod.gen.ts index fad0a3f0048..ac16494f3fe 100644 --- a/packages/contracts/generated/api/console/agent/zod.gen.ts +++ b/packages/contracts/generated/api/console/agent/zod.gen.ts @@ -187,7 +187,9 @@ export const zAgentConfigSnapshotSummaryResponse = z.object({ agent_id: z.string().nullish(), created_at: z.int().nullish(), created_by: z.string().nullish(), + display_version: z.int().nullish(), id: z.string(), + snapshot_version: z.int().nullish(), summary: z.string().nullish(), version: z.int(), version_note: z.string().nullish(), @@ -1702,8 +1704,10 @@ export const zAgentConfigSnapshotDetailResponse = z.object({ config_snapshot: zAgentSoulConfig, created_at: z.int().nullish(), created_by: z.string().nullish(), + display_version: z.int().nullish(), id: z.string(), revisions: z.array(zAgentConfigRevisionResponse).optional(), + snapshot_version: z.int().nullish(), summary: z.string().nullish(), version: z.int(), version_note: z.string().nullish(), @@ -2356,9 +2360,13 @@ export const zGetAgentByAgentIdLogsQuery = z.object({ keyword: z.string().optional(), limit: z.int().gte(1).lte(100).optional().default(20), page: z.int().gte(1).optional().default(1), + sort_by: z.string().optional().default('updated_at'), + sort_order: z.string().optional().default('desc'), source: z.string().optional(), + sources: z.array(z.string()).optional(), start: z.string().optional(), status: z.string().optional(), + statuses: z.array(z.string()).optional(), }) /** @@ -2376,9 +2384,13 @@ export const zGetAgentByAgentIdLogsByConversationIdMessagesQuery = z.object({ keyword: z.string().optional(), limit: z.int().gte(1).lte(100).optional().default(20), page: z.int().gte(1).optional().default(1), + sort_by: z.string().optional().default('updated_at'), + sort_order: z.string().optional().default('desc'), source: z.string().optional(), + sources: z.array(z.string()).optional(), start: z.string().optional(), status: z.string().optional(), + statuses: z.array(z.string()).optional(), }) /** diff --git a/packages/contracts/generated/api/console/apps/types.gen.ts b/packages/contracts/generated/api/console/apps/types.gen.ts index debd7ff1991..26a1b627184 100644 --- a/packages/contracts/generated/api/console/apps/types.gen.ts +++ b/packages/contracts/generated/api/console/apps/types.gen.ts @@ -1710,7 +1710,9 @@ export type AgentConfigSnapshotSummaryResponse = { agent_id?: string | null created_at?: number | null created_by?: string | null + display_version?: number | null id: string + snapshot_version?: number | null summary?: string | null version: number version_note?: string | null diff --git a/packages/contracts/generated/api/console/apps/zod.gen.ts b/packages/contracts/generated/api/console/apps/zod.gen.ts index 83a0b79c624..4449b5c1c70 100644 --- a/packages/contracts/generated/api/console/apps/zod.gen.ts +++ b/packages/contracts/generated/api/console/apps/zod.gen.ts @@ -1736,7 +1736,9 @@ export const zAgentConfigSnapshotSummaryResponse = z.object({ agent_id: z.string().nullish(), created_at: z.int().nullish(), created_by: z.string().nullish(), + display_version: z.int().nullish(), id: z.string(), + snapshot_version: z.int().nullish(), summary: z.string().nullish(), version: z.int(), version_note: z.string().nullish(),