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>
This commit is contained in:
zyssyz123 2026-06-18 20:06:27 +08:00 committed by GitHub
parent aa777a1f7a
commit 0df30dd269
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 356 additions and 91 deletions

View File

@ -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:<app_id> "
"or workflow:<app_id>:<workflow_id>:<version>:<node_id>"
),
)
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,
),

View File

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

View File

@ -680,9 +680,13 @@ Commit an uploaded file into the Agent App drive under files/<name>
| keyword | query | Search query, answer, or conversation name | No | string |
| limit | query | Page size | No | integer, <br>**Default:** 20 |
| page | query | Page number | No | integer, <br>**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, <br>**Default:** updated_at |
| sort_order | query | Sort order: asc or desc | No | string, <br>**Default:** desc |
| source | query | Deprecated single source filter | No | string |
| sources | query | Filter by one or more source IDs, e.g. webapp:<app_id> or workflow:<app_id>:<workflow_id>:<version>:<node_id> | 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/<name>
| keyword | query | Search query, answer, or conversation name | No | string |
| limit | query | Page size | No | integer, <br>**Default:** 20 |
| page | query | Page number | No | integer, <br>**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, <br>**Default:** updated_at |
| sort_order | query | Sort order: asc or desc | No | string, <br>**Default:** desc |
| source | query | Deprecated single source filter | No | string |
| sources | query | Filter by one or more source IDs, e.g. webapp:<app_id> or workflow:<app_id>:<workflow_id>:<version>:<node_id> | 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, <br>**Default:** 20 | Page size | No |
| page | integer, <br>**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, <br>**Default:** updated_at | Sort by created_at or updated_at | No |
| sort_order | string, <br>**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:<app_id> or workflow:<app_id>:<workflow_id>:<version>:<node_id> | 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

View File

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

View File

@ -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"] = [
{

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<AgentConfigRevisionResponse>
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<string>
start?: string
status?: string
statuses?: Array<string>
}
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<string>
start?: string
status?: string
statuses?: Array<string>
}
url: '/agent/{agent_id}/logs/{conversation_id}/messages'
}

View File

@ -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(),
})
/**

View File

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

View File

@ -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(),