diff --git a/backend/apps/agent_app.py b/backend/apps/agent_app.py index 5d47f9f82..05a01ac73 100644 --- a/backend/apps/agent_app.py +++ b/backend/apps/agent_app.py @@ -3,7 +3,7 @@ from http import HTTPStatus from typing import Optional -from fastapi import APIRouter, Body, Header, HTTPException, Request, Query +from fastapi import APIRouter, Body, File, Header, HTTPException, Request, Query, UploadFile from fastapi.encoders import jsonable_encoder from starlette.responses import JSONResponse, Response, StreamingResponse @@ -14,6 +14,8 @@ AgentIDRequest, ConversationResponse, AgentImportRequest, + AgentBatchExportRequest, + AgentBatchImportResult, AgentNameBatchCheckRequest, AgentNameBatchRegenerateRequest, VersionPublishRequest, @@ -51,6 +53,8 @@ get_agent_by_name_impl, export_agent_with_skills_impl, import_agent_with_skills_impl, + export_agents_batch_impl, + import_agents_batch_impl, ) from services.prompt_service import generate_guardrail_rules_impl from services.nl2agent_service import create_nl2agent_stream @@ -354,6 +358,70 @@ async def import_agent_api(request: AgentImportRequest, authorization: Optional[ status_code=HTTPStatus.INTERNAL_SERVER_ERROR, detail="Agent import error.") +@agent_config_router.post("/export/batch") +async def export_agents_batch_api( + request: AgentBatchExportRequest, + authorization: Optional[str] = Header(None) +): + """ + Batch export multiple agents into a single ZIP archive. + + The archive contains ``manifest.json`` plus one folder per agent, each + holding the standard ``agent.json`` (and optional ``skills/*.zip``). + """ + try: + if not request.agent_ids: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="At least one agent_id is required for batch export.") + result = await export_agents_batch_impl( + request.agent_ids, authorization) + return Response( + content=result["data"], + media_type="application/zip", + headers={ + "Content-Disposition": f"attachment; filename=\"{result.get('filename', 'agents_batch_export.zip')}\"" + } + ) + except HTTPException: + raise + except Exception as e: + logger.exception(f"Agent batch export error: {str(e)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Agent batch export error.") + + +@agent_config_router.post("/import/batch", response_model=AgentBatchImportResult) +async def import_agents_batch_api( + file: UploadFile = File(...), + authorization: Optional[str] = Header(None) +): + """ + Batch import agents from a ZIP archive produced by /agent/export/batch. + + Each agent in the archive is imported independently; per-agent failures are + captured in the returned summary instead of aborting the whole request. + """ + try: + zip_bytes = await file.read() + if not zip_bytes: + raise HTTPException( + status_code=HTTPStatus.BAD_REQUEST, + detail="Empty ZIP file received.") + summary = await import_agents_batch_impl(zip_bytes, authorization) + return JSONResponse(status_code=HTTPStatus.OK, content=summary) + except ValueError as e: + raise HTTPException(status_code=HTTPStatus.BAD_REQUEST, detail=str(e)) + except HTTPException: + raise + except Exception as e: + logger.exception(f"Agent batch import error: {str(e)}") + raise HTTPException( + status_code=HTTPStatus.INTERNAL_SERVER_ERROR, + detail="Agent batch import error.") + + @agent_config_router.put("/clear_new/{agent_id}") async def clear_agent_new_mark_api(agent_id: int, authorization: Optional[str] = Header(None)): """ diff --git a/backend/consts/exceptions.py b/backend/consts/exceptions.py index 79215ee4a..3169c47a0 100644 --- a/backend/consts/exceptions.py +++ b/backend/consts/exceptions.py @@ -257,6 +257,11 @@ class SkillDuplicateError(Exception): """Raised when importing an agent with skills that have duplicate names in target tenant.""" def __init__(self, duplicate_names: List[str]): self.duplicate_names = duplicate_names + names_str = ", ".join(duplicate_names) + super().__init__( + f"Skill name conflict: the following skills already exist in your workspace: {names_str}. " + f"Please rename them or delete the existing skills before importing." + ) class SkillException(Exception): diff --git a/backend/consts/model.py b/backend/consts/model.py index 650537bd4..f969a0f9d 100644 --- a/backend/consts/model.py +++ b/backend/consts/model.py @@ -870,6 +870,27 @@ class AgentImportRequest(BaseModel): skills: Optional[List[SkillZipEntry]] = None +class AgentBatchExportRequest(BaseModel): + """Request body for batch agent export. Returns a ZIP archive.""" + agent_ids: List[int] + + +class AgentBatchImportResultItem(BaseModel): + """Single agent result entry inside a batch import summary.""" + name: str + display_name: Optional[str] = None + success: bool + error: Optional[str] = None + + +class AgentBatchImportResult(BaseModel): + """Summary returned after importing a batch agent ZIP archive.""" + total: int + success_count: int + failed_count: int + items: List[AgentBatchImportResultItem] + + class AgentNameBatchRegenerateItem(BaseModel): name: str display_name: Optional[str] = None diff --git a/backend/services/agent_service.py b/backend/services/agent_service.py index ac1d6369c..2f29c43fd 100644 --- a/backend/services/agent_service.py +++ b/backend/services/agent_service.py @@ -3790,7 +3790,7 @@ async def export_agent_with_skills_impl( zip_buffer = io.BytesIO() with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: - zf.writestr("agent.json", agent_json_str) + zf.writestr(_AGENT_JSON_FILENAME, agent_json_str) for entry in skill_zip_entries: skill_zip_bytes = base64.b64decode(entry.skill_zip_base64) zf.writestr(f"skills/{entry.skill_name}.zip", skill_zip_bytes) @@ -3883,6 +3883,293 @@ async def import_agent_with_skills_impl( return agent_id_mapping +# ============================================================================= +# Batch Export / Import +# ============================================================================= + + +_BATCH_MANIFEST_FILENAME = "manifest.json" +_BATCH_AGENTS_PREFIX = "agents/" +_AGENT_JSON_FILENAME = "agent.json" + + +def _sanitize_agent_folder_name(name: str, agent_id: int, used: set) -> str: + """Build a unique, filesystem-safe folder name for one agent in a batch ZIP.""" + import re + + base = re.sub(r"[^A-Za-z0-9_\-]", "_", name or "").strip("_") or f"agent_{agent_id}" + candidate = base + index = 1 + while candidate in used: + index += 1 + candidate = f"{base}_{index}" + used.add(candidate) + return candidate + + +def _write_export_result_to_zip(zf, folder_prefix: str, result) -> None: + """Write a single-agent export result into the batch ZIP archive.""" + if isinstance(result, dict) and result.get("_zip"): + inner_zip_bytes = result["data"] + with zipfile.ZipFile(io.BytesIO(inner_zip_bytes), "r") as inner_zip: + agent_json_content = inner_zip.read(_AGENT_JSON_FILENAME) + zf.writestr( + f"{folder_prefix}/{_AGENT_JSON_FILENAME}", agent_json_content) + for inner_name in inner_zip.namelist(): + if inner_name.startswith("skills/") and inner_name.lower().endswith(".zip"): + zf.writestr( + f"{folder_prefix}/{inner_name}", + inner_zip.read(inner_name), + ) + else: + zf.writestr( + f"{folder_prefix}/{_AGENT_JSON_FILENAME}", json.dumps(result)) + + +async def export_agents_batch_impl( + agent_ids: List[int], + authorization: str, +) -> dict: + """Export multiple independent agent trees into a single ZIP archive. + + Archive layout:: + + manifest.json + agents//agent.json + agents//skills/.zip + + Each ``agent.json`` is the standard ``ExportAndImportDataFormat`` payload + produced by :func:`export_agent_with_skills_impl`, so a batch archive is a + collection of independent single-agent exports. + + Returns a dict ``{"_zip": True, "data": bytes, "filename": str}`` mirroring + the single-agent export contract so the app layer can stream it unchanged. + """ + _, tenant_id, _ = get_current_user_info(authorization) + + manifest = { + "version": "1.0", + "exported_at": "", # filled below without extra imports + "agents": [], + } + + zip_buffer = io.BytesIO() + used_folders: set = set() + + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zf: + for agent_id in agent_ids: + agent_row = search_agent_info_by_agent_id( + agent_id=agent_id, tenant_id=tenant_id, version_no=0 + ) + if not agent_row: + logger.warning( + f"Batch export: agent {agent_id} not found, skipping") + continue + + agent_name = agent_row.get("name") or f"agent_{agent_id}" + folder_name = _sanitize_agent_folder_name( + agent_name, agent_id, used_folders) + folder_prefix = f"{_BATCH_AGENTS_PREFIX}{folder_name}" + + result = await export_agent_with_skills_impl( + agent_id, authorization + ) + + _write_export_result_to_zip(zf, folder_prefix, result) + + manifest["agents"].append({ + "folder": folder_prefix, + "agent_id": agent_id, + "name": agent_name, + "display_name": agent_row.get("display_name"), + }) + + zf.writestr( + _BATCH_MANIFEST_FILENAME, + json.dumps(manifest, ensure_ascii=False), + ) + + zip_buffer.seek(0) + return { + "_zip": True, + "data": zip_buffer.read(), + "filename": "agents_batch_export.zip", + } + + +async def import_agents_batch_impl( + zip_bytes: bytes, + authorization: str, +) -> Dict[str, Any]: + """Import every agent tree contained in a batch export ZIP archive. + + Each ``agents//agent.json`` is imported independently via the + existing :func:`import_agent_with_skills_impl` / + :func:`import_agent_impl` flow. Per-agent failures are captured and do not + abort the remaining imports. + + Returns a summary dict matching :class:`AgentBatchImportResult`. + """ + summary: Dict[str, Any] = { + "total": 0, + "success_count": 0, + "failed_count": 0, + "items": [], + } + + try: + zf = zipfile.ZipFile(io.BytesIO(zip_bytes), "r") + except Exception as e: + raise ValueError(f"Invalid batch export ZIP file: {e}") + + with zf: + names = zf.namelist() + agent_folders = _discover_agent_folders(zf, names) + summary["total"] = len(agent_folders) + + for folder in agent_folders: + agent_json_path = f"{folder}/{_AGENT_JSON_FILENAME}" + try: + item = await _import_single_agent_from_zip( + zf, names, folder, authorization) + if item["success"]: + summary["success_count"] += 1 + else: + summary["failed_count"] += 1 + summary["items"].append(item) + except SkillDuplicateError as e: + logger.warning( + f"Batch import skill duplicate for folder {folder}: {e.duplicate_names}") + item = _build_import_failure_item( + zf, agent_json_path, folder, + f"Skill name conflict: {', '.join(e.duplicate_names)}. " + f"Please rename them or delete the existing skills before importing.") + summary["failed_count"] += 1 + summary["items"].append(item) + except Exception as e: + logger.exception( + f"Batch import failed for folder {folder}: {e}") + item = _build_import_failure_item( + zf, agent_json_path, folder, str(e)) + summary["failed_count"] += 1 + summary["items"].append(item) + + return summary + + +def _discover_agent_folders(zf, names: List[str]) -> List[str]: + """Discover agent folders from a batch ZIP archive, preferring the manifest.""" + if _BATCH_MANIFEST_FILENAME in names: + try: + manifest = json.loads(zf.read(_BATCH_MANIFEST_FILENAME)) + return [ + entry["folder"] for entry in manifest.get("agents", []) + if isinstance(entry, dict) and entry.get("folder") + ] + except Exception as e: + raise ValueError(f"Failed to parse batch manifest: {e}") + + seen: set = set() + folders: List[str] = [] + for name in names: + if not name.startswith(_BATCH_AGENTS_PREFIX): + continue + remainder = name[len(_BATCH_AGENTS_PREFIX):] + parts = remainder.split("/") + if len(parts) >= 2 and parts[1] == _AGENT_JSON_FILENAME: + folder = f"{_BATCH_AGENTS_PREFIX}{parts[0]}" + if folder not in seen: + seen.add(folder) + folders.append(folder) + return folders + + +def _collect_skill_entries( + zf, names: List[str], folder: str +) -> List[SkillZipEntry]: + """Collect and encode skill ZIP entries for a given agent folder.""" + entries: List[SkillZipEntry] = [] + skills_prefix = f"{folder}/skills/" + for name in names: + if name.startswith(skills_prefix) and name.lower().endswith(".zip"): + skill_bytes = zf.read(name) + skill_name = name[len(skills_prefix):][:-4] + entries.append(SkillZipEntry( + skill_name=skill_name, + skill_zip_base64=base64.b64encode(skill_bytes).decode("ascii"), + )) + return entries + + +def _extract_agent_metadata(payload: dict, folder: str) -> tuple: + """Extract (agent_name, display_name) from an agent payload.""" + main_info = payload.get("agent_info", {}).get( + str(payload.get("agent_id")), {}) + display_name = main_info.get("display_name") or \ + main_info.get("name") or folder + agent_name = main_info.get("name") or folder + return agent_name, display_name + + +def _build_import_failure_item( + zf, agent_json_path: str, folder: str, error: str +) -> dict: + """Build a failure item dict for the batch import summary.""" + try: + payload = json.loads(zf.read(agent_json_path).decode("utf-8")) + agent_name, display_name = _extract_agent_metadata(payload, folder) + except Exception: + agent_name = folder + display_name = None + return { + "name": agent_name, + "display_name": display_name, + "success": False, + "error": error, + } + + +async def _import_single_agent_from_zip( + zf, names: List[str], folder: str, authorization: str +) -> dict: + """Import one agent from a batch ZIP folder. Returns a summary item dict.""" + agent_json_path = f"{folder}/{_AGENT_JSON_FILENAME}" + if agent_json_path not in names: + return { + "name": folder, + "display_name": None, + "success": False, + "error": f"{_AGENT_JSON_FILENAME} not found in folder", + } + + agent_json_str = zf.read(agent_json_path).decode("utf-8") + agent_payload = json.loads(agent_json_str) + agent_info = ExportAndImportDataFormat.model_validate(agent_payload) + skill_entries = _collect_skill_entries(zf, names, folder) + agent_name, display_name = _extract_agent_metadata(agent_payload, folder) + + if skill_entries: + await import_agent_with_skills_impl( + agent_info=agent_info, + skills=skill_entries, + authorization=authorization, + force_import=False, + ) + else: + await import_agent_impl( + agent_info=agent_info, + authorization=authorization, + force_import=False, + ) + + return { + "name": agent_name, + "display_name": display_name, + "success": True, + "error": None, + } + + # ============================================================================= # Sandbox Policy Builder # ============================================================================= diff --git a/frontend/app/[locale]/agent-space/components/MineAgentsView.tsx b/frontend/app/[locale]/agent-space/components/MineAgentsView.tsx index 42d6fb9b8..f2f4eeb69 100644 --- a/frontend/app/[locale]/agent-space/components/MineAgentsView.tsx +++ b/frontend/app/[locale]/agent-space/components/MineAgentsView.tsx @@ -3,12 +3,17 @@ import { useEffect, useRef, useState } from "react"; import { useParams, useRouter } from "next/navigation"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { App, Button, Empty, Input, Spin } from "antd"; -import { ChevronLeft, ChevronRight, Plus, Search, Upload } from "lucide-react"; +import { App, Button, Empty, Input, Modal, Spin, Tag } from "antd"; +import { ChevronLeft, ChevronRight, Download, Plus, Search, Upload } from "lucide-react"; import { useTranslation } from "react-i18next"; import AgentImportWizard from "@/components/agent/AgentImportWizard"; import { useConfirmModal } from "@/hooks/useConfirmModal"; -import { deleteAgent } from "@/services/agentConfigService"; +import { + deleteAgent, + exportAgentsBatch, + importAgentsBatch, + type AgentBatchImportResult, +} from "@/services/agentConfigService"; import { AGENTS_LIST_QUERY_KEY, invalidateAgentRepositoryCaches, @@ -16,7 +21,8 @@ import { useUpdateAgentRepositoryStatus, } from "@/hooks/agentRepository/useAgentRepositoryListings"; import { - openImportWizardWithFile, + parseAgentImportFile, + selectImportFile, type ImportAgentData, } from "@/lib/agentImportUtils"; import log from "@/lib/logger"; @@ -119,6 +125,18 @@ export function MineAgentsView({ useState(null); const consumedDeepLinkRef = useRef(null); + // Batch export / import state + const [selectionMode, setSelectionMode] = useState(false); + const [selectedAgentIds, setSelectedAgentIds] = useState>( + new Set() + ); + const [isBatchExporting, setIsBatchExporting] = useState(false); + const [isBatchImporting, setIsBatchImporting] = useState(false); + const [batchImportResult, setBatchImportResult] = + useState(null); + const [batchImportResultVisible, setBatchImportResultVisible] = + useState(false); + const createListingMutation = useCreateAgentRepositoryListing(); const updateStatusMutation = useUpdateAgentRepositoryStatus(); const deleteAgentMutation = useMutation({ @@ -132,15 +150,103 @@ export function MineAgentsView({ }; const handleImportAgent = async () => { - await openImportWizardWithFile({ - onSuccess: (agentData) => { - setImportWizardData(agentData); - setImportWizardVisible(true); + const selection = await selectImportFile(); + + if (selection.type === "cancelled") { + return; + } + + if (selection.type === "batch") { + setIsBatchImporting(true); + try { + const result = await importAgentsBatch(selection.file); + if (result.success && result.data) { + const data = result.data; + if (data.failed_count > 0) { + setBatchImportResult(data); + setBatchImportResultVisible(true); + } else { + message.success( + t("agentRepository.mine.batchImport.success", { + success: data.success_count, + failed: data.failed_count, + }) + ); + } + await Promise.all([ + invalidateAgentRepositoryCaches(queryClient), + queryClient.invalidateQueries({ + queryKey: [AGENTS_LIST_QUERY_KEY], + }), + ]); + } else { + message.error(result.message || t("agentRepository.mine.batchImport.failed")); + } + } finally { + setIsBatchImporting(false); + } + return; + } + + // Single agent path: parse the file and open the wizard. + const data = await parseAgentImportFile(selection.file, { + onParseError: (msgKey) => { + message.error(t(msgKey) || msgKey); + }, + onValidationError: (msgKey) => { + message.error(t(msgKey) || msgKey); + }, + onGenericError: (error) => { + log.error("Failed to read import file:", error); + message.error(t("businessLogic.config.error.agentImportFailed") || "Failed to import agent"); }, - message: message, - t: t, - log: log, }); + + if (data) { + setImportWizardData(data); + setImportWizardVisible(true); + } + }; + + const handleToggleSelect = (agentId: number) => { + setSelectedAgentIds((prev) => { + const next = new Set(prev); + if (next.has(agentId)) { + next.delete(agentId); + } else { + next.add(agentId); + } + return next; + }); + }; + + const handleEnterSelectMode = () => { + setSelectionMode(true); + setSelectedAgentIds(new Set()); + }; + + const handleExitSelectMode = () => { + setSelectionMode(false); + setSelectedAgentIds(new Set()); + }; + + const handleBatchExport = async () => { + if (selectedAgentIds.size === 0) { + message.warning(t("agentRepository.mine.batchExport.empty")); + return; + } + setIsBatchExporting(true); + try { + const result = await exportAgentsBatch(Array.from(selectedAgentIds)); + if (result.success) { + message.success(t("agentRepository.mine.batchExport.success")); + handleExitSelectMode(); + } else { + message.error(result.message || t("agentRepository.mine.batchExport.failed")); + } + } finally { + setIsBatchExporting(false); + } }; const handleEdit = (agentId: number, permission?: MyEditableAgentItem["permission"]) => { @@ -384,21 +490,57 @@ export function MineAgentsView({ />
- - + {selectionMode ? ( + <> + + {t("agentRepository.mine.batchExport.selected", { + count: selectedAgentIds.size, + })} + + + + + ) : ( + <> + + + + + )}
@@ -481,6 +623,9 @@ export function MineAgentsView({ deleteAgentMutation.isPending && deleteAgentMutation.variables === agent.agent_id } + selectionMode={selectionMode} + isSelected={selectedAgentIds.has(agent.agent_id)} + onToggleSelect={() => handleToggleSelect(agent.agent_id)} /> ) @@ -562,6 +707,60 @@ export function MineAgentsView({ ]); }} /> + + setBatchImportResultVisible(false)} + onOk={() => setBatchImportResultVisible(false)} + okText={t("common.ok", "OK")} + cancelButtonProps={{ style: { display: "none" } }} + > + {batchImportResult ? ( +
+
+ + {t("agentRepository.mine.batchImport.successLabel")}:{" "} + {batchImportResult.success_count} + + + {t("agentRepository.mine.batchImport.failedLabel")}:{" "} + {batchImportResult.failed_count} + +
+ {batchImportResult.items.length > 0 ? ( +
+ {batchImportResult.items.map((item, idx) => ( +
+
+ + {item.display_name || item.name} + + + {item.success + ? t("agentRepository.mine.batchImport.successLabel") + : t("agentRepository.mine.batchImport.failedLabel")} + +
+ {!item.success && item.error ? ( +

+ {item.error} +

+ ) : null} +
+ ))} +
+ ) : null} +
+ ) : null} +
); } diff --git a/frontend/app/[locale]/agent-space/components/MyAgentCard.tsx b/frontend/app/[locale]/agent-space/components/MyAgentCard.tsx index 7caa1e52a..77fb9e1e3 100644 --- a/frontend/app/[locale]/agent-space/components/MyAgentCard.tsx +++ b/frontend/app/[locale]/agent-space/components/MyAgentCard.tsx @@ -1,6 +1,6 @@ "use client"; -import { Button, Card, Dropdown } from "antd"; +import { Button, Card, Checkbox, Dropdown } from "antd"; import type { MenuProps } from "antd"; import { Bot, @@ -32,6 +32,12 @@ interface MyAgentCardProps { onEvaluate: () => void; isApplying?: boolean; isDeleting?: boolean; + /** When true the card renders a selection checkbox and toggles selection on click. */ + selectionMode?: boolean; + /** Whether this card is currently selected (only used in selection mode). */ + isSelected?: boolean; + /** Toggle selection for this card. */ + onToggleSelect?: () => void; } const MENU_ACTION_I18N: Record = { @@ -52,35 +58,16 @@ const STATUS_BADGE_CLASS: Record< "bg-red-50 text-red-700 dark:bg-red-500/10 dark:text-red-300", }; -export function MyAgentCard({ - agent, - onEdit, - onView, - onApplyListing, - onViewReview, - onDelete, - onEvaluate, - isApplying = false, - isDeleting = false, -}: MyAgentCardProps) { - const { t } = useTranslation("common"); - - const title = agent.name?.trim() || t("agentRepository.card.untitled"); - const description = - agent.description?.trim() || t("agentRepository.card.noDescription"); - const published = (agent.current_version_no ?? 0) > 0; - const repositoryInfo = agent.repository_info ?? []; - const hasRepositoryInfo = repositoryInfo.length > 0; - const repositoryStatusBadge = - getMineCardRepositoryStatusBadge(repositoryInfo); - const footerDate = formatMineDate(agent.version_create_time); - const versionLabel = agent.version_label; - const canEdit = agent.permission !== "READ_ONLY"; - const canView = (agent.current_version_no ?? 0) > 0; - const canEvaluate = canView; - const menuActions = getMineCardMenuActions(agent); - - const menuItems: MenuProps["items"] = menuActions.map((action) => { +function buildMenuItems( + menuActions: MineCardMenuAction[], + isApplying: boolean, + isDeleting: boolean, + onApplyListing: () => void, + onViewReview: (mode: "review" | "reviewUpdate") => void, + onDelete: () => void, + t: (key: string) => string +): MenuProps["items"] { + const items: MenuProps["items"] = menuActions.map((action) => { const icon = action === "apply" ? ( @@ -96,18 +83,18 @@ export function MyAgentCard({ onClick: () => { if (action === "apply") { onApplyListing(); - return; + } else { + onViewReview(action === "reviewUpdate" ? "reviewUpdate" : "review"); } - onViewReview(action === "reviewUpdate" ? "reviewUpdate" : "review"); }, }; }); if (menuActions.length > 0) { - menuItems.push({ type: "divider" }); + items.push({ type: "divider" }); } - menuItems.push({ + items.push({ key: "delete", danger: true, icon: , @@ -116,9 +103,68 @@ export function MyAgentCard({ onClick: onDelete, }); + return items; +} + +function handleCardClick( + e: React.MouseEvent, + selectionMode: boolean, + onToggleSelect?: () => void +) { + if (!selectionMode) return; + const target = e.target as HTMLElement; + if (target.closest("button, a, .ant-checkbox-wrapper")) return; + onToggleSelect?.(); +} + +export function MyAgentCard({ + agent, + onEdit, + onView, + onApplyListing, + onViewReview, + onDelete, + onEvaluate, + isApplying = false, + isDeleting = false, + selectionMode = false, + isSelected = false, + onToggleSelect, +}: MyAgentCardProps) { + const { t } = useTranslation("common"); + + const title = agent.name?.trim() || t("agentRepository.card.untitled"); + const description = + agent.description?.trim() || t("agentRepository.card.noDescription"); + const published = (agent.current_version_no ?? 0) > 0; + const repositoryInfo = agent.repository_info ?? []; + const hasRepositoryInfo = repositoryInfo.length > 0; + const repositoryStatusBadge = + getMineCardRepositoryStatusBadge(repositoryInfo); + const footerDate = formatMineDate(agent.version_create_time); + const versionLabel = agent.version_label; + const canEdit = agent.permission !== "READ_ONLY"; + const canView = (agent.current_version_no ?? 0) > 0; + const canEvaluate = canView; + const menuActions = getMineCardMenuActions(agent); + + const menuItems = buildMenuItems( + menuActions, + isApplying, + isDeleting, + onApplyListing, + onViewReview, + onDelete, + t + ); + return ( handleCardClick(e, selectionMode, onToggleSelect)} >
-
- -
+ {selectionMode ? ( + { + e.stopPropagation(); + onToggleSelect?.(); + }} + className="mt-1 shrink-0" + aria-label={t("agentRepository.mine.batchExport.selectMode")} + /> + ) : ( +
+ +
+ )}

diff --git a/frontend/app/[locale]/agents/components/AgentManageComp.tsx b/frontend/app/[locale]/agents/components/AgentManageComp.tsx index f20aef867..51663e639 100644 --- a/frontend/app/[locale]/agents/components/AgentManageComp.tsx +++ b/frontend/app/[locale]/agents/components/AgentManageComp.tsx @@ -12,9 +12,11 @@ import { useAuthorizationContext } from "@/components/providers/AuthorizationPro import log from "@/lib/logger"; import { useState } from "react"; import { - openImportWizardWithFile, + parseAgentImportFile, + selectImportFile, type ImportAgentData, } from "@/lib/agentImportUtils"; +import { importAgentsBatch } from "@/services/agentConfigService"; import AgentImportWizard from "@/components/agent/AgentImportWizard"; @@ -32,21 +34,67 @@ export default function AgentManageComp() { const [importWizardVisible, setImportWizardVisible] = useState(false); const [importWizardData, setImportWizardData] = useState(null); + const [isBatchImporting, setIsBatchImporting] = useState(false); // Always resolve tenant from auth on the agent dev page (matches published_list; avoids stale/wrong tenant_id query params) const { agents: agentList, isLoading: loading, refetch } = useAgentList(""); // Handle import agent for space view - open wizard instead of direct import const handleImportAgent = async () => { - await openImportWizardWithFile({ - onSuccess: (agentData) => { - setImportWizardData(agentData); - setImportWizardVisible(true); + const showImportSuccess = (successCount: number, failedCount: number) => { + message.success( + t("agentRepository.mine.batchImport.success", { + success: successCount, + failed: failedCount, + }) + ); + }; + + const showImportError = (msg: string) => { + message.error(msg); + }; + + const selection = await selectImportFile(); + + if (selection.type === "cancelled") { + return; + } + + if (selection.type === "batch") { + setIsBatchImporting(true); + try { + const result = await importAgentsBatch(selection.file); + if (result.success && result.data) { + const data = result.data; + showImportSuccess(data.success_count, data.failed_count); + refetch(); + } else { + showImportError(result.message || t("agentRepository.mine.batchImport.failed")); + } + } finally { + setIsBatchImporting(false); + } + return; + } + + // Single agent path: parse and open the wizard. + const data = await parseAgentImportFile(selection.file, { + onParseError: (msgKey) => { + showImportError(t(msgKey) || msgKey); + }, + onValidationError: (msgKey) => { + showImportError(t(msgKey) || msgKey); + }, + onGenericError: (error) => { + log.error("Failed to read import file:", error); + showImportError(t("businessLogic.config.error.agentImportFailed") || "Failed to import agent"); }, - message: message, - t: t, - log: log, }); + + if (data) { + setImportWizardData(data); + setImportWizardVisible(true); + } }; return ( @@ -129,7 +177,9 @@ export default function AgentManageComp() {
void handleImportAgent()} > diff --git a/frontend/lib/agentImportUtils.ts b/frontend/lib/agentImportUtils.ts index 3e06a311b..34fd664da 100644 --- a/frontend/lib/agentImportUtils.ts +++ b/frontend/lib/agentImportUtils.ts @@ -40,6 +40,34 @@ export const extractSkillNameFromPath = (path: string): string => { return filename.replace(/\.zip$/i, ""); }; +/** + * Check whether a ZIP file is a batch agent export (contains manifest.json + * at the archive root and at least one agents//agent.json entry). + * + * Returns false for plain single-agent export ZIPs (which contain agent.json + * at the archive root and no manifest). + */ +export const isBatchExportZip = async (file: File): Promise => { + if (!file.name.toLowerCase().endsWith(".zip")) { + return false; + } + try { + const zip = await JSZip.loadAsync(file); + const manifestFile = zip.file("manifest.json"); + if (!manifestFile) { + return false; + } + const manifest = JSON.parse(await manifestFile.async("string")); + return Boolean( + manifest && + Array.isArray(manifest.agents) && + manifest.agents.length > 0 + ); + } catch { + return false; + } +}; + export interface ParseAgentFileOptions { onFileNotFound?: (message: string) => void; onParseError?: (message: string) => void; @@ -144,6 +172,33 @@ export function selectFile( }); } +/** + * Result of selecting an import file with batch detection. + * - "cancelled": no file selected + * - "batch": file is a batch agent export ZIP (multiple agents) + * - "single": file is a single-agent JSON/ZIP that should go through the wizard + */ +export type SelectImportFileResult = + | { type: "cancelled" } + | { type: "batch"; file: File } + | { type: "single"; file: File }; + +/** + * Select an import file and detect whether it is a batch agent export ZIP. + * Batch ZIPs (containing manifest.json + agents/*) are routed to the batch + * import endpoint; everything else falls back to the single-agent wizard. + */ +export async function selectImportFile(): Promise { + const file = await selectFile(".json,.zip"); + if (!file) { + return { type: "cancelled" }; + } + if (await isBatchExportZip(file)) { + return { type: "batch", file }; + } + return { type: "single", file }; +} + /** * Open import wizard with file selection * This is a convenience function that combines file selection and parsing diff --git a/frontend/public/locales/en/common.json b/frontend/public/locales/en/common.json index 0e6de5c5d..3b86725a4 100644 --- a/frontend/public/locales/en/common.json +++ b/frontend/public/locales/en/common.json @@ -1,4 +1,4 @@ -{ +{ "assistant.name": "{productName}", "mainPage.layout.title": "{productName} | AI Agents", "mainPage.layout.titleTemplate": "%s | {productName} AI Agents", @@ -1979,6 +1979,20 @@ "agentRepository.mine.filter.others": "Others", "agentRepository.mine.createNewAgent": "Create new agent", "agentRepository.mine.newAgentButton": "New agent", + "agentRepository.mine.exportButton": "Export", + "agentRepository.mine.batchExport.selectMode": "Select", + "agentRepository.mine.batchExport.cancelSelect": "Cancel", + "agentRepository.mine.batchExport.selected": "{{count}} selected", + "agentRepository.mine.batchExport.empty": "Please select agents to export first", + "agentRepository.mine.batchExport.success": "Batch export successful", + "agentRepository.mine.batchExport.failed": "Batch export failed", + "agentRepository.mine.batchImport.success": "Batch import complete: {{success}} succeeded, {{failed}} failed", + "agentRepository.mine.batchImport.partialSuccess": "Some agents failed to import, see details", + "agentRepository.mine.batchImport.failed": "Batch import failed", + "agentRepository.mine.batchImport.importing": "Batch importing...", + "agentRepository.mine.batchImport.resultTitle": "Batch Import Result", + "agentRepository.mine.batchImport.successLabel": "Succeeded", + "agentRepository.mine.batchImport.failedLabel": "Failed", "agentRepository.mine.empty": "No editable agents yet", "agentRepository.mine.emptyFiltered": "No agents match the current filter", "agentRepository.mine.loadError": "Failed to load your agents. Please try again later.", diff --git a/frontend/public/locales/zh/common.json b/frontend/public/locales/zh/common.json index ed29bca9e..42e919a5c 100644 --- a/frontend/public/locales/zh/common.json +++ b/frontend/public/locales/zh/common.json @@ -1,4 +1,4 @@ -{ +{ "assistant.name": "{productName}", "mainPage.layout.title": "{productName} | 智能问答", "mainPage.layout.titleTemplate": "%s | {productName} 智能问答", @@ -1944,6 +1944,20 @@ "agentRepository.mine.filter.others": "其它", "agentRepository.mine.createNewAgent": "创建新的智能体", "agentRepository.mine.newAgentButton": "新建智能体", + "agentRepository.mine.exportButton": "导出", + "agentRepository.mine.batchExport.selectMode": "批量选择", + "agentRepository.mine.batchExport.cancelSelect": "取消选择", + "agentRepository.mine.batchExport.selected": "已选 {{count}} 个", + "agentRepository.mine.batchExport.empty": "请先选择要导出的智能体", + "agentRepository.mine.batchExport.success": "批量导出成功", + "agentRepository.mine.batchExport.failed": "批量导出失败", + "agentRepository.mine.batchImport.success": "批量导入完成:成功 {{success}} 个,失败 {{failed}} 个", + "agentRepository.mine.batchImport.partialSuccess": "部分智能体导入失败,请查看详情", + "agentRepository.mine.batchImport.failed": "批量导入失败", + "agentRepository.mine.batchImport.importing": "正在批量导入...", + "agentRepository.mine.batchImport.resultTitle": "批量导入结果", + "agentRepository.mine.batchImport.successLabel": "成功", + "agentRepository.mine.batchImport.failedLabel": "失败", "agentRepository.mine.empty": "暂无可编辑的智能体", "agentRepository.mine.emptyFiltered": "当前筛选下暂无智能体", "agentRepository.mine.loadError": "加载我的智能体失败,请稍后重试", diff --git a/frontend/services/agentConfigService.ts b/frontend/services/agentConfigService.ts index 63b4d4e13..08dcf5822 100644 --- a/frontend/services/agentConfigService.ts +++ b/frontend/services/agentConfigService.ts @@ -662,6 +662,114 @@ export const importAgent = async ( } }; +/** + * Batch export result item returned by /agent/import/batch + */ +export interface AgentBatchImportResultItem { + name: string; + display_name?: string | null; + success: boolean; + error?: string | null; +} + +export interface AgentBatchImportResult { + total: number; + success_count: number; + failed_count: number; + items: AgentBatchImportResultItem[]; +} + +/** + * Batch export multiple agents into a single ZIP archive. + * Triggers a browser download of the resulting ZIP file. + * @param agentIds list of agent ids to export + */ +export const exportAgentsBatch = async (agentIds: number[]) => { + try { + const response = await fetch(API_ENDPOINTS.agent.exportBatch, { + method: "POST", + headers: getAuthHeaders(), + body: JSON.stringify({ agent_ids: agentIds }), + }); + + if (!response.ok) { + throw new Error(`Request failed: ${response.status}`); + } + + const blob = await response.blob(); + const contentDisposition = response.headers.get("Content-Disposition"); + const filename = + extractFilenameFromContentDisposition(contentDisposition) || + "agents_batch_export.zip"; + downloadBlob(blob, filename); + + return { + success: true, + data: null, + message: "Agents batch exported as ZIP", + }; + } catch (error) { + log.error("Failed to batch export agents:", error); + return { + success: false, + data: null, + message: "Failed to batch export agents, please try again later", + }; + } +}; + +/** + * Batch import agents from a ZIP archive produced by exportAgentsBatch. + * @param file ZIP file containing multiple agent exports + */ +export const importAgentsBatch = async ( + file: File +): Promise<{ + success: boolean; + data: AgentBatchImportResult | null; + message: string; +}> => { + try { + const formData = new FormData(); + formData.append("file", file); + + // Do not set Content-Type for FormData; browser sets multipart boundary. + const headers: Record = { + "User-Agent": "AgentFrontEnd/1.0", + }; + + const response = await fetch(API_ENDPOINTS.agent.importBatch, { + method: "POST", + headers, + body: formData, + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error( + errorData?.detail || `Request failed: ${response.status}` + ); + } + + const data = await response.json(); + return { + success: true, + data: data as AgentBatchImportResult, + message: "Agents batch imported successfully", + }; + } catch (error) { + log.error("Failed to batch import agents:", error); + return { + success: false, + data: null, + message: + error instanceof Error + ? error.message + : "Failed to batch import agents, please try again later", + }; + } +}; + /** * Clear NEW mark for an agent */ diff --git a/frontend/services/api.ts b/frontend/services/api.ts index fd14d9f34..7515d145c 100644 --- a/frontend/services/api.ts +++ b/frontend/services/api.ts @@ -89,6 +89,8 @@ export const API_ENDPOINTS = { `${API_BASE_URL}/agent/stop/${conversationId}`, export: `${API_BASE_URL}/agent/export`, import: `${API_BASE_URL}/agent/import`, + exportBatch: `${API_BASE_URL}/agent/export/batch`, + importBatch: `${API_BASE_URL}/agent/import/batch`, checkNameBatch: `${API_BASE_URL}/agent/check_name`, regenerateNameBatch: `${API_BASE_URL}/agent/regenerate_name`, searchInfo: `${API_BASE_URL}/agent/search_info`, diff --git a/test/backend/apps/test_agent_batch_api.py b/test/backend/apps/test_agent_batch_api.py new file mode 100644 index 000000000..1c004f150 --- /dev/null +++ b/test/backend/apps/test_agent_batch_api.py @@ -0,0 +1,626 @@ +"""Unit tests for batch import/export API endpoints in backend/apps/agent_app.py. + +Tests use FastAPI's TestClient against the agent_config_router with stubbed +services so the request/response shape can be validated without touching +the database or external services. +""" + +import sys +import types +from typing import Any, Dict, List, Optional +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI, HTTPException +from fastapi.testclient import TestClient +from pydantic import BaseModel, ConfigDict + + +# Path setup +sys.path.insert( + 0, + __import__("os").path.join(__import__("os").path.dirname(__file__), "../../.."), +) + + +# ===== Stub third-party dependencies ===== + +# -- consts package -- +consts_pkg = types.ModuleType("consts") +sys.modules["consts"] = consts_pkg + +# consts.const +const_mod = types.ModuleType("consts.const") +const_mod.ASSET_OWNER_TENANT_ID = "asset_owner" +sys.modules["consts.const"] = const_mod +consts_pkg.const = const_mod + +# consts.exceptions +exceptions_mod = types.ModuleType("consts.exceptions") + + +class ForbiddenError(Exception): + pass + + +class SkillDuplicateError(Exception): + def __init__(self, duplicate_names=None): + self.duplicate_names = duplicate_names or [] + super().__init__() + + +class AppException(Exception): + pass + + +class UnauthorizedError(Exception): + pass + + +exceptions_mod.ForbiddenError = ForbiddenError +exceptions_mod.SkillDuplicateError = SkillDuplicateError +exceptions_mod.AppException = AppException +exceptions_mod.UnauthorizedError = UnauthorizedError +sys.modules["consts.exceptions"] = exceptions_mod +consts_pkg.exceptions = exceptions_mod + + +# -- consts.model -- +# All models must be real Pydantic BaseModel subclasses so FastAPI can +# register routes and parse/serialize request/response bodies. + +class _StubBase(BaseModel): + """Base class for stub models that accept extra fields.""" + model_config = ConfigDict(extra="allow") + + +class AgentRequest(_StubBase): + query: str = "" + agent_id: Optional[int] = None + is_debug: Optional[bool] = False + + +class NL2AgentRunRequest(_StubBase): + query: str = "" + + +class AgentInfoRequest(_StubBase): + agent_id: Optional[int] = None + name: Optional[str] = None + + +class AgentIDRequest(BaseModel): + agent_id: int + + +class ConversationResponse(_StubBase): + code: int = 0 + message: str = "success" + data: Any = None + + +class AgentImportRequest(_StubBase): + agent_info: Any = None + force_import: bool = False + skills: Optional[list] = None + + +class AgentBatchExportRequest(BaseModel): + agent_ids: List[int] + + +class AgentBatchImportResultItem(BaseModel): + name: str + display_name: Optional[str] = None + success: bool + error: Optional[str] = None + + +class AgentBatchImportResult(BaseModel): + total: int + success_count: int + failed_count: int + items: List[AgentBatchImportResultItem] + + +class AgentNameBatchCheckItem(BaseModel): + name: str + display_name: Optional[str] = None + agent_id: Optional[int] = None + + +class AgentNameBatchCheckRequest(BaseModel): + items: List[AgentNameBatchCheckItem] + + +class AgentNameBatchRegenerateItem(BaseModel): + name: str + display_name: Optional[str] = None + task_description: Optional[str] = "" + agent_id: Optional[int] = None + + +class AgentNameBatchRegenerateRequest(BaseModel): + items: List[AgentNameBatchRegenerateItem] + + +class VersionPublishRequest(_StubBase): + version_name: Optional[str] = None + release_note: Optional[str] = None + publish_as_a2a: bool = False + + +class VersionListItemResponse(_StubBase): + id: int = 0 + version_no: int = 0 + status: str = "RELEASED" + + +class VersionListResponse(BaseModel): + items: List[VersionListItemResponse] = [] + total: int = 0 + + +class VersionDetailResponse(_StubBase): + id: int = 0 + version_no: int = 0 + status: str = "RELEASED" + + +class VersionRollbackRequest(_StubBase): + version_name: Optional[str] = None + release_note: Optional[str] = None + + +class VersionStatusRequest(BaseModel): + status: str + + +class VersionUpdateRequest(_StubBase): + version_name: Optional[str] = None + release_note: Optional[str] = None + + +class VersionCompareRequest(BaseModel): + version_no_a: int + version_no_b: int + + +class CurrentVersionResponse(_StubBase): + version_no: int = 0 + status: str = "RELEASED" + + +model_mod = types.ModuleType("consts.model") +model_mod.AgentRequest = AgentRequest +model_mod.AgentInfoRequest = AgentInfoRequest +model_mod.AgentIDRequest = AgentIDRequest +model_mod.ConversationResponse = ConversationResponse +model_mod.AgentImportRequest = AgentImportRequest +model_mod.AgentBatchExportRequest = AgentBatchExportRequest +model_mod.AgentBatchImportResult = AgentBatchImportResult +model_mod.AgentBatchImportResultItem = AgentBatchImportResultItem +model_mod.AgentNameBatchCheckRequest = AgentNameBatchCheckRequest +model_mod.AgentNameBatchCheckItem = AgentNameBatchCheckItem +model_mod.AgentNameBatchRegenerateRequest = AgentNameBatchRegenerateRequest +model_mod.AgentNameBatchRegenerateItem = AgentNameBatchRegenerateItem +model_mod.VersionPublishRequest = VersionPublishRequest +model_mod.VersionListResponse = VersionListResponse +model_mod.VersionListItemResponse = VersionListItemResponse +model_mod.VersionDetailResponse = VersionDetailResponse +model_mod.VersionRollbackRequest = VersionRollbackRequest +model_mod.VersionStatusRequest = VersionStatusRequest +model_mod.CurrentVersionResponse = CurrentVersionResponse +model_mod.VersionCompareRequest = VersionCompareRequest +model_mod.VersionUpdateRequest = VersionUpdateRequest +model_mod.NL2AgentRunRequest = NL2AgentRunRequest +sys.modules["consts.model"] = model_mod +consts_pkg.model = model_mod + + +# -- services package -- +services_pkg = types.ModuleType("services") +sys.modules["services"] = services_pkg + +# services.agent_service +agent_service_mod = types.ModuleType("services.agent_service") + +# The two functions under test — pre-create as AsyncMock with defaults +agent_service_mod.export_agents_batch_impl = AsyncMock( + return_value={"data": b"fake-zip-data", "filename": "agents_batch_export.zip"} +) +agent_service_mod.import_agents_batch_impl = AsyncMock( + return_value={ + "total": 2, + "success_count": 2, + "failed_count": 0, + "items": [ + {"name": "agent1", "display_name": "Agent 1", "success": True, "error": None}, + {"name": "agent2", "display_name": "Agent 2", "success": True, "error": None}, + ], + } +) + +_all_agent_service_funcs = [ + "get_agent_info_impl", + "get_creating_sub_agent_info_impl", + "update_agent_info_impl", + "delete_agent_impl", + "export_agent_impl", + "import_agent_impl", + "check_agent_name_conflict_batch_impl", + "regenerate_agent_name_batch_impl", + "list_all_agent_info_impl", + "run_agent_stream", + "stop_agent_tasks", + "get_agent_call_relationship_impl", + "clear_agent_new_mark_impl", + "get_agent_by_name_impl", + "export_agent_with_skills_impl", + "import_agent_with_skills_impl", +] +for _name in _all_agent_service_funcs: + setattr(agent_service_mod, _name, MagicMock()) + +sys.modules["services.agent_service"] = agent_service_mod +services_pkg.agent_service = agent_service_mod + +# services.asset_owner_visibility +asset_owner_mod = types.ModuleType("services.asset_owner_visibility") +asset_owner_mod.apply_agent_detail_prompt_visibility = MagicMock() +sys.modules["services.asset_owner_visibility"] = asset_owner_mod +services_pkg.asset_owner_visibility = asset_owner_mod + +# services.prompt_service +prompt_service_mod = types.ModuleType("services.prompt_service") +prompt_service_mod.generate_guardrail_rules_impl = MagicMock() +sys.modules["services.prompt_service"] = prompt_service_mod +services_pkg.prompt_service = prompt_service_mod + +# services.nl2agent_service +nl2agent_service_mod = types.ModuleType("services.nl2agent_service") +nl2agent_service_mod.create_nl2agent_stream = MagicMock() +sys.modules["services.nl2agent_service"] = nl2agent_service_mod +services_pkg.nl2agent_service = nl2agent_service_mod + +# services.agent_version_service +agent_version_mod = types.ModuleType("services.agent_version_service") +_version_funcs = [ + "publish_version_impl", + "get_version_list_impl", + "get_version_impl", + "get_version_detail_impl", + "_get_version_detail_or_draft", + "rollback_version_impl", + "update_version_status_impl", + "update_version_impl", + "delete_version_impl", + "get_current_version_impl", + "compare_versions_impl", + "list_published_agents_impl", +] +for _name in _version_funcs: + setattr(agent_version_mod, _name, MagicMock()) +sys.modules["services.agent_version_service"] = agent_version_mod +services_pkg.agent_version_service = agent_version_mod + +# -- utils.auth_utils -- +auth_utils_mod = types.ModuleType("utils.auth_utils") +auth_utils_mod.get_current_user_info = MagicMock( + return_value=("user1", "tenant1", "en") +) +auth_utils_mod.get_current_user_id = MagicMock( + return_value=("user1", "tenant1") +) +sys.modules["utils.auth_utils"] = auth_utils_mod + +# Also register under backend.utils.auth_utils for safety +backend_utils_pkg = types.ModuleType("backend.utils") +sys.modules["backend.utils"] = backend_utils_pkg +backend_utils_pkg.auth_utils = auth_utils_mod +sys.modules["backend.utils.auth_utils"] = auth_utils_mod + + +# Default return values for resetting mocks between tests +_EXPORT_DEFAULT = {"data": b"fake-zip-data", "filename": "agents_batch_export.zip"} +_IMPORT_DEFAULT = { + "total": 2, + "success_count": 2, + "failed_count": 0, + "items": [ + {"name": "agent1", "display_name": "Agent 1", "success": True, "error": None}, + {"name": "agent2", "display_name": "Agent 2", "success": True, "error": None}, + ], +} + + +@pytest.fixture +def client(): + """Build a TestClient with mocked services for batch API tests. + + Resets the AsyncMock state before each test so call counts and + side_effects do not leak across tests. + """ + from apps import agent_app + + export_mock = agent_service_mod.export_agents_batch_impl + import_mock = agent_service_mod.import_agents_batch_impl + + export_mock.reset_mock() + import_mock.reset_mock() + + export_mock.return_value = _EXPORT_DEFAULT + export_mock.side_effect = None + import_mock.return_value = _IMPORT_DEFAULT + import_mock.side_effect = None + + app = FastAPI() + app.include_router(agent_app.agent_config_router) + cli = TestClient(app, raise_server_exceptions=False) + return cli, { + "export": export_mock, + "import": import_mock, + } + + +class TestExportAgentsBatchApi: + """Tests for POST /agent/export/batch — export_agents_batch_api.""" + + def test_export_batch_success(self, client): + """成功导出,验证返回 ZIP 流及正确的响应头。""" + cli, svc = client + svc["export"].return_value = { + "data": b"fake-zip-data", + "filename": "custom_name.zip", + } + + response = cli.post( + "/agent/export/batch", + json={"agent_ids": [1, 2, 3]}, + headers={"Authorization": "Bearer test-token"}, + ) + + assert response.status_code == 200 + assert response.headers["content-type"] == "application/zip" + assert "attachment" in response.headers.get("content-disposition", "") + assert "custom_name.zip" in response.headers.get("content-disposition", "") + assert response.content == b"fake-zip-data" + svc["export"].assert_awaited_once() + + def test_export_batch_empty_ids(self, client): + """空 agent_ids 列表,应返回 400。""" + cli, svc = client + + response = cli.post( + "/agent/export/batch", + json={"agent_ids": []}, + headers={"Authorization": "Bearer test-token"}, + ) + + assert response.status_code == 400 + assert "agent_id" in response.json()["detail"].lower() + svc["export"].assert_not_awaited() + + def test_export_batch_value_error(self, client): + """服务层抛 ValueError,export_agents_batch_api 将其作为通用 + Exception 捕获,返回 500。""" + cli, svc = client + svc["export"].side_effect = ValueError("invalid agent id") + + response = cli.post( + "/agent/export/batch", + json={"agent_ids": [1]}, + headers={"Authorization": "Bearer test-token"}, + ) + + assert response.status_code == 500 + svc["export"].assert_awaited_once() + + def test_export_batch_http_exception(self, client): + """服务层抛 HTTPException,应透传状态码。""" + cli, svc = client + svc["export"].side_effect = HTTPException( + status_code=403, detail="forbidden" + ) + + response = cli.post( + "/agent/export/batch", + json={"agent_ids": [1]}, + headers={"Authorization": "Bearer test-token"}, + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "forbidden" + svc["export"].assert_awaited_once() + + def test_export_batch_generic_error(self, client): + """服务层抛通用异常,应返回 500。""" + cli, svc = client + svc["export"].side_effect = RuntimeError("unexpected failure") + + response = cli.post( + "/agent/export/batch", + json={"agent_ids": [1]}, + headers={"Authorization": "Bearer test-token"}, + ) + + assert response.status_code == 500 + svc["export"].assert_awaited_once() + + def test_export_batch_default_filename(self, client): + """服务层返回值中无 filename 时,使用默认文件名。""" + cli, svc = client + svc["export"].return_value = {"data": b"fake-zip-data"} + + response = cli.post( + "/agent/export/batch", + json={"agent_ids": [1]}, + headers={"Authorization": "Bearer test-token"}, + ) + + assert response.status_code == 200 + assert "agents_batch_export.zip" in response.headers.get( + "content-disposition", "" + ) + + +class TestImportAgentsBatchApi: + """Tests for POST /agent/import/batch — import_agents_batch_api.""" + + def test_import_batch_success(self, client): + """成功导入,验证返回摘要结构。""" + cli, svc = client + svc["import"].return_value = { + "total": 2, + "success_count": 2, + "failed_count": 0, + "items": [ + { + "name": "agent1", + "display_name": "Agent 1", + "success": True, + "error": None, + }, + { + "name": "agent2", + "display_name": "Agent 2", + "success": True, + "error": None, + }, + ], + } + + zip_content = b"PK\x03\x04" + b"fake-zip-content" + response = cli.post( + "/agent/import/batch", + files={"file": ("agents.zip", zip_content, "application/zip")}, + headers={"Authorization": "Bearer test-token"}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["total"] == 2 + assert body["success_count"] == 2 + assert body["failed_count"] == 0 + assert len(body["items"]) == 2 + assert body["items"][0]["success"] is True + svc["import"].assert_awaited_once() + + def test_import_batch_empty_file(self, client): + """空 ZIP 文件,应返回 400。""" + cli, svc = client + + response = cli.post( + "/agent/import/batch", + files={"file": ("empty.zip", b"", "application/zip")}, + headers={"Authorization": "Bearer test-token"}, + ) + + assert response.status_code == 400 + assert "empty" in response.json()["detail"].lower() + svc["import"].assert_not_awaited() + + def test_import_batch_value_error(self, client): + """服务层抛 ValueError,应返回 400。""" + cli, svc = client + svc["import"].side_effect = ValueError("invalid zip structure") + + zip_content = b"PK\x03\x04" + b"fake-zip-content" + response = cli.post( + "/agent/import/batch", + files={"file": ("agents.zip", zip_content, "application/zip")}, + headers={"Authorization": "Bearer test-token"}, + ) + + assert response.status_code == 400 + assert "invalid zip structure" in response.json()["detail"] + svc["import"].assert_awaited_once() + + def test_import_batch_http_exception(self, client): + """服务层抛 HTTPException,应透传。""" + cli, svc = client + svc["import"].side_effect = HTTPException( + status_code=403, detail="permission denied" + ) + + zip_content = b"PK\x03\x04" + b"fake-zip-content" + response = cli.post( + "/agent/import/batch", + files={"file": ("agents.zip", zip_content, "application/zip")}, + headers={"Authorization": "Bearer test-token"}, + ) + + assert response.status_code == 403 + assert response.json()["detail"] == "permission denied" + svc["import"].assert_awaited_once() + + def test_import_batch_generic_error(self, client): + """服务层抛通用异常,应返回 500。""" + cli, svc = client + svc["import"].side_effect = RuntimeError("disk write failed") + + zip_content = b"PK\x03\x04" + b"fake-zip-content" + response = cli.post( + "/agent/import/batch", + files={"file": ("agents.zip", zip_content, "application/zip")}, + headers={"Authorization": "Bearer test-token"}, + ) + + assert response.status_code == 500 + svc["import"].assert_awaited_once() + + def test_import_batch_partial_failure(self, client): + """部分 agent 导入失败,应在摘要中体现。""" + cli, svc = client + svc["import"].return_value = { + "total": 3, + "success_count": 2, + "failed_count": 1, + "items": [ + { + "name": "agent1", + "display_name": "Agent 1", + "success": True, + "error": None, + }, + { + "name": "agent2", + "display_name": "Agent 2", + "success": True, + "error": None, + }, + { + "name": "agent3", + "display_name": "Agent 3", + "success": False, + "error": "skill conflict", + }, + ], + } + + zip_content = b"PK\x03\x04" + b"fake-zip-content" + response = cli.post( + "/agent/import/batch", + files={"file": ("agents.zip", zip_content, "application/zip")}, + headers={"Authorization": "Bearer test-token"}, + ) + + assert response.status_code == 200 + body = response.json() + assert body["total"] == 3 + assert body["success_count"] == 2 + assert body["failed_count"] == 1 + assert body["items"][2]["success"] is False + assert body["items"][2]["error"] == "skill conflict" + + def test_import_batch_missing_file(self, client): + """未上传文件,应返回 422。""" + cli, svc = client + + response = cli.post( + "/agent/import/batch", + headers={"Authorization": "Bearer test-token"}, + ) + + assert response.status_code == 422 + svc["import"].assert_not_awaited() \ No newline at end of file diff --git a/test/backend/services/test_agent_batch_import_export.py b/test/backend/services/test_agent_batch_import_export.py new file mode 100644 index 000000000..86f47aa8d --- /dev/null +++ b/test/backend/services/test_agent_batch_import_export.py @@ -0,0 +1,1514 @@ +import sys +import io +import json +import zipfile +import base64 +import types +from unittest.mock import patch, MagicMock, AsyncMock + +import pytest + +# ============================================================================= +# STEP 1: Set up sys.modules mocks BEFORE any backend imports +# ============================================================================= + +email_validator_mock = types.ModuleType("email_validator") + + +class MockEmailNotValidError(ValueError): + pass + + +def mock_validate_email(email, check_deliverability=False): + local_part = email.split("@", 1)[0] + return types.SimpleNamespace(normalized=email, local_part=local_part) + +email_validator_mock.EmailNotValidError = MockEmailNotValidError +email_validator_mock.validate_email = mock_validate_email +sys.modules["email_validator"] = email_validator_mock + +try: + import pydantic.networks as pydantic_networks + original_package_version = pydantic_networks.version + pydantic_networks.version = ( + lambda package_name: "2.0.0" + if package_name == "email-validator" + else original_package_version(package_name) + ) +except Exception: + pass + + +class MockToolConfig: + def __init__(self, *args, **kwargs): + for key, value in kwargs.items(): + setattr(self, key, value) + + def model_dump(self, **kwargs): + return {k: v for k, v in self.__dict__.items() if not k.startswith("_")} + + +nexent_agent_model_mock = MagicMock() +nexent_agent_model_mock.ToolConfig = MockToolConfig +sys.modules["nexent"] = MagicMock() +sys.modules["nexent.core"] = MagicMock() +sys.modules["nexent.core.agents"] = MagicMock() +sys.modules["nexent.core.agents.agent_model"] = nexent_agent_model_mock +sys.modules["nexent.core.agents.run_agent"] = MagicMock() + +context_input_mock = types.ModuleType("nexent.core.agents.context_input") + + +class MockContextInput: + def __init__(self, items=()): + self.items = items + + +context_input_mock.ContextInput = MockContextInput +sys.modules["nexent.core.agents.context_input"] = context_input_mock + +context_items_mock = types.ModuleType("nexent.core.agents.context") +context_items_mock.ContextItemInput = MagicMock() +sys.modules["nexent.core.agents.context"] = context_items_mock + +sys.modules["nexent.core.models"] = MagicMock() +sys.modules["nexent.core.utils"] = MagicMock() + + +class MockProcessType: + class MODEL_OUTPUT_CODE: + value = "model_output_code" + + class MODEL_OUTPUT_THINKING: + value = "model_output_thinking" + + class MODEL_OUTPUT_DEEP_THINKING: + value = "model_output_deep_thinking" + + class STEP_COUNT: + value = "step_count" + + class TOOL: + value = "tool" + + class EXECUTION_LOGS: + value = "execution_logs" + + class SKILL_ARTIFACT: + value = "skill_artifact" + + +sys.modules["nexent.core.utils.observer"] = MagicMock() +sys.modules["nexent.core.utils.observer"].ProcessType = MockProcessType + +rerank_module = MagicMock() +rerank_module.BaseRerank = type("BaseRerank", (), {}) +rerank_module.OpenAICompatibleRerank = type("OpenAICompatibleRerank", (), {}) +sys.modules["nexent.core.models.rerank_model"] = rerank_module + +sys.modules["nexent.memory"] = MagicMock() +sys.modules["nexent.memory.memory_service"] = MagicMock() +sys.modules["nexent.storage"] = MagicMock() +sys.modules["nexent.storage.storage_client_factory"] = MagicMock() +sys.modules["nexent.storage.minio_config"] = MagicMock() +sys.modules["nexent.monitor"] = MagicMock() +sys.modules["nexent.monitor.monitoring"] = MagicMock() + +sys.modules["boto3"] = MagicMock() +sys.modules["elasticsearch"] = MagicMock() +sys.modules["sqlalchemy"] = MagicMock() + +sys.modules["database.agent_db"] = MagicMock() +sys.modules["database.tool_db"] = MagicMock() +sys.modules["database.remote_mcp_db"] = MagicMock() +sys.modules["database.agent_version_db"] = MagicMock() +sys.modules["database.group_db"] = MagicMock() +sys.modules["database.user_tenant_db"] = MagicMock() +sys.modules["database.model_management_db"] = MagicMock() +sys.modules["database.a2a_agent_db"] = MagicMock() +sys.modules["database.skill_db"] = MagicMock() +sys.modules["database.attachment_db"] = MagicMock() + +_mock_db_client = MagicMock() +_mock_db_client.get_db_session = MagicMock() +_mock_db_client.as_dict = MagicMock() +_mock_db_client.MinioClient = MagicMock() +_mock_db_client.db_client = MagicMock() +sys.modules["database.client"] = _mock_db_client +sys.modules["backend.database.client"] = _mock_db_client + +services_module = types.ModuleType("services") +services_module.__path__ = [] +sys.modules["services"] = services_module + +runtime_state_service_module = types.ModuleType("services.runtime_state_service") +runtime_state_service_mock = MagicMock() +runtime_state_service_mock.enabled = False +runtime_state_service_mock.is_cancelled_async = AsyncMock(return_value=False) +runtime_state_service_mock.get_run_state_async = AsyncMock(return_value={}) +runtime_state_service_module.runtime_state_service = runtime_state_service_mock +sys.modules["services.runtime_state_service"] = runtime_state_service_module + +conversation_management_service_mock = MagicMock() +memory_config_service_mock = MagicMock() +agent_version_service_mock = MagicMock() +skill_service_mock = MagicMock() +skill_service_mock.SkillService.return_value.list_skill_instances.return_value = [] +prompt_template_service_mock = MagicMock() +prompt_template_service_mock.SYSTEM_PROMPT_TEMPLATE_ID = 0 +prompt_template_service_mock.SYSTEM_PROMPT_TEMPLATE_NAME = "system_default" +prompt_template_service_mock.get_prompt_template_summary = MagicMock(return_value=(None, None)) +prompt_template_service_mock.resolve_prompt_generate_template = MagicMock(return_value={}) + +sys.modules["services.conversation_management_service"] = conversation_management_service_mock +sys.modules["services.memory_config_service"] = memory_config_service_mock +sys.modules["services.agent_version_service"] = agent_version_service_mock +sys.modules["services.skill_service"] = skill_service_mock +sys.modules["services.prompt_template_service"] = prompt_template_service_mock +sys.modules["services.file_management_service"] = MagicMock() +sys.modules["services.streaming_channel"] = MagicMock() + + +class AsyncChannelMock: + async def publish(self, *args, **kwargs): + pass + + async def close(self, *args, **kwargs): + pass + + +streaming_channel_manager_mock = MagicMock() +streaming_channel_manager_mock.get_or_create_channel = AsyncMock(return_value=AsyncChannelMock()) +streaming_channel_manager_mock.remove_channel = AsyncMock(return_value=None) +streaming_channel_manager_mock.publish = AsyncMock(return_value=None) +streaming_channel_manager_mock.complete_channel = AsyncMock(return_value=None) +sys.modules["services.streaming_channel"].streaming_channel_manager = streaming_channel_manager_mock + +setattr(services_module, "skill_service", sys.modules["services.skill_service"]) + +import importlib.util +from pathlib import Path + +_asset_owner_path = Path(__file__).resolve().parents[3] / "backend" / "services" / "asset_owner_visibility.py" +_asset_owner_spec = importlib.util.spec_from_file_location( + "services.asset_owner_visibility", _asset_owner_path +) +_asset_owner_mod = importlib.util.module_from_spec(_asset_owner_spec) +_asset_owner_spec.loader.exec_module(_asset_owner_mod) +sys.modules["services.asset_owner_visibility"] = _asset_owner_mod +setattr(services_module, "asset_owner_visibility", _asset_owner_mod) + +sys.modules["agents"] = MagicMock() +sys.modules["agents.create_agent_info"] = MagicMock() +sys.modules["agents.agent_run_manager"] = MagicMock() +sys.modules["agents.preprocess_manager"] = MagicMock() + +mock_create_agent_info = MagicMock() +mock_create_agent_info.create_tool_config_list = AsyncMock(return_value=[]) +sys.modules["agents.create_agent_info"].create_agent_info = mock_create_agent_info + +sys.modules["utils"] = MagicMock() +sys.modules["utils.auth_utils"] = MagicMock() +sys.modules["utils.thread_utils"] = MagicMock() +sys.modules["utils.context_utils"] = MagicMock() + + +def mock_convert_list_to_string(items): + if not items: + return "" + return ",".join(str(item) for item in items) + + +sys.modules["utils.str_utils"] = MagicMock() +sys.modules["utils.str_utils"].convert_list_to_string = mock_convert_list_to_string +sys.modules["utils.str_utils"].convert_string_to_list = lambda s: s.split(",") if s else [] +sys.modules["utils.config_utils"] = MagicMock() +sys.modules["utils.prompt_template_utils"] = MagicMock() +sys.modules["utils.llm_utils"] = MagicMock() +sys.modules["utils.monitoring"] = MagicMock() + +# ============================================================================= +# STEP 2: Create mock objects for database clients +# ============================================================================= + +mock_engine = MagicMock() +mock_session_maker = MagicMock() +mock_db_session = MagicMock() +mock_session_maker.return_value = mock_db_session + +mock_postgres_client = MagicMock() +mock_postgres_client.session_maker = mock_session_maker + +minio_client_mock = MagicMock() + + +def mock_get_db_session(db_session=None): + session = mock_db_session if db_session is None else db_session + from contextlib import contextmanager + + @contextmanager + def _mock_context(): + yield session + + return _mock_context() + + +mock_backend_database_client = MagicMock() +mock_backend_database_client.PostgresClient = MagicMock(return_value=mock_postgres_client) +mock_backend_database_client.get_db_session = mock_get_db_session +mock_backend_database_client.MinioClient = MagicMock(return_value=minio_client_mock) +mock_backend_database_client.db_client = mock_postgres_client +sys.modules["backend.database.client"] = mock_backend_database_client + +sys.modules["nexent.storage.storage_client_factory"].create_storage_client_from_config = MagicMock(return_value=MagicMock()) + +# ============================================================================= +# STEP 3: Import backend modules after all mocks are in place +# ============================================================================= + +monitoring_manager_mock = MagicMock() + + +def pass_through_decorator(*args, **kwargs): + def decorator(func): + return func + + return decorator + + +monitoring_manager_mock.monitor_endpoint = pass_through_decorator +monitoring_manager_mock.monitor_llm_call = pass_through_decorator +monitoring_manager_mock.setup_fastapi_app = MagicMock(return_value=True) +monitoring_manager_mock.configure = MagicMock() +monitoring_manager_mock.add_span_event = MagicMock() +monitoring_manager_mock.set_span_attributes = MagicMock() + +sys.modules["nexent.monitor"].get_monitoring_manager = lambda: monitoring_manager_mock +sys.modules["nexent.monitor"].monitoring_manager = monitoring_manager_mock +sys.modules["utils.monitoring"].monitoring_manager = monitoring_manager_mock +sys.modules["utils.monitoring"].setup_fastapi_app = MagicMock(return_value=True) + +sys.modules["nexent.storage.minio_config"].MinIOStorageConfig = type( + "MinIOStorageConfig", (), {"validate": lambda self: None} +) + +import backend.services.agent_service as agent_service + +from backend.services.agent_service import ( + export_agents_batch_impl, + import_agents_batch_impl, + _sanitize_agent_folder_name, + _write_export_result_to_zip, + _discover_agent_folders, + _collect_skill_entries, + _extract_agent_metadata, + _build_import_failure_item, + _import_single_agent_from_zip, +) + +from consts.model import ( + ExportAndImportAgentInfo, + ExportAndImportDataFormat, + MCPInfo, + SkillZipEntry, +) +from consts.exceptions import SkillDuplicateError + + +# ============================================================================= +# Fixtures +# ============================================================================= + +@pytest.fixture(autouse=True) +def reset_mocks(): + yield + + +@pytest.fixture +def mock_authorization(): + return "Bearer test_token" + + +@pytest.fixture +def mock_user_info(): + return ("test_user_id", "test_tenant_id", "en") + + +@pytest.fixture +def sample_agent_payload(): + return { + "agent_id": 1, + "agent_info": { + "1": { + "agent_id": 1, + "tenant_id": "test_tenant", + "name": "test_agent", + "display_name": "Test Agent", + "description": "A test agent", + "business_description": "", + "author": "test_author", + "max_steps": 10, + "provide_run_summary": False, + "enabled": True, + "tools": [], + "managed_agents": [], + } + }, + "mcp_info": [], + } + + +@pytest.fixture +def sample_export_result(): + agent_json_bytes = json.dumps( + { + "agent_id": 1, + "agent_info": { + "1": { + "agent_id": 1, + "tenant_id": "test_tenant", + "name": "test_agent", + "display_name": "Test Agent", + "description": "A test agent", + "business_description": "", + "author": "test_author", + "max_steps": 10, + "provide_run_summary": False, + "enabled": True, + "tools": [], + "managed_agents": [], + } + }, + "mcp_info": [], + } + ).encode("utf-8") + + inner_buffer = io.BytesIO() + with zipfile.ZipFile(inner_buffer, "w", zipfile.ZIP_DEFLATED) as inner_zf: + inner_zf.writestr("agent.json", agent_json_bytes) + inner_zf.writestr( + "skills/test_skill.zip", + b"fake_skill_zip_content", + ) + + return { + "_zip": True, + "data": inner_buffer.getvalue(), + "filename": "agent_1_export.zip", + } + + +# ============================================================================= +# Tests for _sanitize_agent_folder_name +# ============================================================================= + + +class TestSanitizeAgentFolderName: + + def test_normal_name(self): + used = set() + result = _sanitize_agent_folder_name("MyAgent", 1, used) + assert result == "MyAgent" + assert result in used + + def test_name_with_special_characters(self): + used = set() + result = _sanitize_agent_folder_name("My Agent!@#", 1, used) + assert result == "My_Agent" + assert result in used + + def test_empty_name_uses_default(self): + used = set() + result = _sanitize_agent_folder_name("", 42, used) + assert result == "agent_42" + assert result in used + + def test_none_name_uses_default(self): + used = set() + result = _sanitize_agent_folder_name(None, 42, used) + assert result == "agent_42" + assert result in used + + def test_duplicate_name_gets_suffix(self): + used = set() + _sanitize_agent_folder_name("MyAgent", 1, used) + result = _sanitize_agent_folder_name("MyAgent", 2, used) + assert result == "MyAgent_2" + assert result in used + + def test_multiple_duplicates_get_incremental_suffix(self): + used = set() + _sanitize_agent_folder_name("MyAgent", 1, used) + r2 = _sanitize_agent_folder_name("MyAgent", 2, used) + r3 = _sanitize_agent_folder_name("MyAgent", 3, used) + r4 = _sanitize_agent_folder_name("MyAgent", 4, used) + assert r2 == "MyAgent_2" + assert r3 == "MyAgent_3" + assert r4 == "MyAgent_4" + + def test_name_with_only_special_chars(self): + used = set() + result = _sanitize_agent_folder_name("!@#$%", 1, used) + assert result == "agent_1" + assert result in used + + def test_unicode_name(self): + used = set() + result = _sanitize_agent_folder_name("我的智能体", 1, used) + assert result == "agent_1" + assert result in used + + def test_underscore_trimming(self): + used = set() + result = _sanitize_agent_folder_name("__MyAgent__", 1, used) + assert result == "MyAgent" + assert result in used + + +# ============================================================================= +# Tests for _write_export_result_to_zip +# ============================================================================= + + +class TestWriteExportResultToZip: + + def test_write_zip_result_with_skills(self, sample_export_result): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + _write_export_result_to_zip(zf, "agents/test_agent", sample_export_result) + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + names = zf.namelist() + assert "agents/test_agent/agent.json" in names + assert "agents/test_agent/skills/test_skill.zip" in names + + def test_write_plain_dict_result(self): + buffer = io.BytesIO() + plain_result = {"key": "value", "name": "test"} + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + _write_export_result_to_zip(zf, "agents/plain_agent", plain_result) + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + names = zf.namelist() + assert "agents/plain_agent/agent.json" in names + content = json.loads(zf.read("agents/plain_agent/agent.json").decode("utf-8")) + assert content == plain_result + + def test_write_dict_without_zip_flag(self, sample_export_result): + buffer = io.BytesIO() + result_without_flag = {"key": "value", "name": "test"} + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + _write_export_result_to_zip(zf, "agents/no_zip", result_without_flag) + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + names = zf.namelist() + assert "agents/no_zip/agent.json" in names + + def test_write_zip_result_empty_inner_zip(self): + buffer = io.BytesIO() + inner_buffer = io.BytesIO() + with zipfile.ZipFile(inner_buffer, "w") as inner_zf: + inner_zf.writestr("agent.json", '{"agent_id": 1}') + + result = {"_zip": True, "data": inner_buffer.getvalue()} + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + _write_export_result_to_zip(zf, "agents/no_skills", result) + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + names = zf.namelist() + assert "agents/no_skills/agent.json" in names + content = json.loads(zf.read("agents/no_skills/agent.json").decode("utf-8")) + assert content == {"agent_id": 1} + + +# ============================================================================= +# Tests for _discover_agent_folders +# ============================================================================= + + +class TestDiscoverAgentFolders: + + def test_discover_from_manifest(self): + manifest = { + "version": "1.0", + "exported_at": "", + "agents": [ + {"folder": "agents/agent_one", "agent_id": 1}, + {"folder": "agents/agent_two", "agent_id": 2}, + ], + } + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("manifest.json", json.dumps(manifest)) + zf.writestr("agents/agent_one/agent.json", "{}") + zf.writestr("agents/agent_two/agent.json", "{}") + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + names = zf.namelist() + folders = _discover_agent_folders(zf, names) + assert folders == ["agents/agent_one", "agents/agent_two"] + + def test_discover_fallback_to_scanning(self): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("agents/alpha/agent.json", "{}") + zf.writestr("agents/beta/agent.json", "{}") + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + names = zf.namelist() + folders = _discover_agent_folders(zf, names) + assert "agents/alpha" in folders + assert "agents/beta" in folders + + def test_discover_skips_non_agent_files(self): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("agents/alpha/agent.json", "{}") + zf.writestr("agents/alpha/skills/test.zip", b"test") + zf.writestr("readme.txt", "some text") + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + names = zf.namelist() + folders = _discover_agent_folders(zf, names) + assert folders == ["agents/alpha"] + + def test_discover_empty_zip(self): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("manifest.json", json.dumps({"version": "1.0", "agents": []})) + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + names = zf.namelist() + folders = _discover_agent_folders(zf, names) + assert folders == [] + + def test_discover_manifest_parsing_error(self): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("manifest.json", "invalid json{{{") + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + names = zf.namelist() + with pytest.raises(ValueError, match="Failed to parse batch manifest"): + _discover_agent_folders(zf, names) + + def test_discover_manifest_with_missing_folder_key(self): + manifest = { + "version": "1.0", + "agents": [ + {"agent_id": 1}, + {"folder": "agents/valid"}, + "not_a_dict", + ], + } + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("manifest.json", json.dumps(manifest)) + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + names = zf.namelist() + folders = _discover_agent_folders(zf, names) + assert folders == ["agents/valid"] + + def test_discover_scanning_deduplicates_folders(self): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("agents/shared/agent.json", "{}") + zf.writestr("agents/shared/skills/skill1.zip", b"test") + zf.writestr("agents/shared/skills/skill2.zip", b"test") + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + names = zf.namelist() + folders = _discover_agent_folders(zf, names) + assert folders == ["agents/shared"] + + def test_discover_scanning_skips_duplicate_folder_entries(self): + """When the same folder appears twice in names, it should be deduplicated.""" + from backend.services.agent_service import _discover_agent_folders + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("agents/test_agent/agent.json", "{}") + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + names = ["agents/test_agent/agent.json", "agents/test_agent/agent.json"] + folders = _discover_agent_folders(zf, names) + assert folders == ["agents/test_agent"] + + +# ============================================================================= +# Tests for _collect_skill_entries +# ============================================================================= + + +class TestCollectSkillEntries: + + def test_collect_skills_from_folder(self): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("agents/test/agent.json", "{}") + zf.writestr("agents/test/skills/skill_a.zip", b"skill_a_data") + zf.writestr("agents/test/skills/skill_b.zip", b"skill_b_data") + zf.writestr("agents/test/other_file.txt", "text") + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + names = zf.namelist() + entries = _collect_skill_entries(zf, names, "agents/test") + assert len(entries) == 2 + assert entries[0].skill_name == "skill_a" + assert entries[1].skill_name == "skill_b" + assert isinstance(entries[0].skill_zip_base64, str) + + def test_collect_no_skills(self): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("agents/test/agent.json", "{}") + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + names = zf.namelist() + entries = _collect_skill_entries(zf, names, "agents/test") + assert entries == [] + + def test_collect_skills_empty_folder(self): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + pass + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + names = zf.namelist() + entries = _collect_skill_entries(zf, names, "agents/nonexistent") + assert entries == [] + + def test_collect_skill_name_parsing(self): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("agents/test/skills/my_skill.zip", b"data") + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + names = zf.namelist() + entries = _collect_skill_entries(zf, names, "agents/test") + assert len(entries) == 1 + assert entries[0].skill_name == "my_skill" + + def test_collect_skill_base64_encoding(self): + buffer = io.BytesIO() + skill_content = b"test_skill_content" + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("agents/test/skills/enc.zip", skill_content) + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + names = zf.namelist() + entries = _collect_skill_entries(zf, names, "agents/test") + decoded = base64.b64decode(entries[0].skill_zip_base64) + assert decoded == skill_content + + +# ============================================================================= +# Tests for _extract_agent_metadata +# ============================================================================= + + +class TestExtractAgentMetadata: + + def test_extract_with_name_and_display_name(self, sample_agent_payload): + agent_name, display_name = _extract_agent_metadata(sample_agent_payload, "agents/test") + assert agent_name == "test_agent" + assert display_name == "Test Agent" + + def test_extract_fallback_to_folder(self): + payload = {"agent_id": 1, "agent_info": {}, "mcp_info": []} + agent_name, display_name = _extract_agent_metadata(payload, "agents/my_folder") + assert agent_name == "agents/my_folder" + assert display_name == "agents/my_folder" + + def test_extract_display_name_fallback_to_name(self): + payload = { + "agent_id": 1, + "agent_info": { + "1": { + "name": "agent_name_only", + } + }, + } + agent_name, display_name = _extract_agent_metadata(payload, "agents/test") + assert agent_name == "agent_name_only" + assert display_name == "agent_name_only" + + def test_extract_empty_agent_info(self): + payload = {"agent_id": 1, "agent_info": {"1": {}}, "mcp_info": []} + agent_name, display_name = _extract_agent_metadata(payload, "agents/fallback") + assert agent_name == "agents/fallback" + assert display_name == "agents/fallback" + + def test_extract_with_none_values(self): + payload = { + "agent_id": 1, + "agent_info": { + "1": {"name": None, "display_name": None} + }, + } + agent_name, display_name = _extract_agent_metadata(payload, "agents/fallback") + assert agent_name == "agents/fallback" + assert display_name == "agents/fallback" + + +# ============================================================================= +# Tests for _build_import_failure_item +# ============================================================================= + + +class TestBuildImportFailureItem: + + def test_build_failure_item_with_valid_json(self, sample_agent_payload): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr( + "agents/test/agent.json", + json.dumps(sample_agent_payload).encode("utf-8"), + ) + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + item = _build_import_failure_item( + zf, "agents/test/agent.json", "agents/test", "Test error" + ) + assert item["name"] == "test_agent" + assert item["display_name"] == "Test Agent" + assert item["success"] is False + assert item["error"] == "Test error" + + def test_build_failure_item_with_invalid_json(self): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("agents/test/agent.json", "not valid json") + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + item = _build_import_failure_item( + zf, "agents/test/agent.json", "agents/test", "Parse error" + ) + assert item["name"] == "agents/test" + assert item["display_name"] is None + assert item["success"] is False + assert item["error"] == "Parse error" + + def test_build_failure_item_missing_file(self): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + pass + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + item = _build_import_failure_item( + zf, "agents/nonexistent/agent.json", "agents/nonexistent", "Missing" + ) + assert item["name"] == "agents/nonexistent" + assert item["display_name"] is None + assert item["success"] is False + assert item["error"] == "Missing" + + +# ============================================================================= +# Tests for _import_single_agent_from_zip +# ============================================================================= + + +class TestImportSingleAgentFromZip: + + @pytest.mark.asyncio + async def test_import_with_skills_success(self, mock_authorization): + payload = { + "agent_id": 1, + "agent_info": { + "1": { + "agent_id": 1, + "tenant_id": "test_tenant", + "name": "test_agent", + "display_name": "Test Agent", + "description": "desc", + "business_description": "", + "author": "author", + "max_steps": 10, + "provide_run_summary": False, + "enabled": True, + "tools": [], + "managed_agents": [], + } + }, + "mcp_info": [], + } + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr( + "agents/test/agent.json", + json.dumps(payload).encode("utf-8"), + ) + zf.writestr("agents/test/skills/skill1.zip", b"skill_data") + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + names = zf.namelist() + mock_import = AsyncMock(return_value={"agent_id": 1}) + with patch( + "backend.services.agent_service.import_agent_with_skills_impl", + mock_import, + ): + item = await _import_single_agent_from_zip( + zf, names, "agents/test", mock_authorization + ) + + assert item["name"] == "test_agent" + assert item["display_name"] == "Test Agent" + assert item["success"] is True + mock_import.assert_called_once() + + @pytest.mark.asyncio + async def test_import_without_skills_success(self, mock_authorization): + payload = { + "agent_id": 2, + "agent_info": { + "2": { + "agent_id": 2, + "tenant_id": "test_tenant", + "name": "simple_agent", + "display_name": "Simple Agent", + "description": "desc", + "business_description": "", + "author": "author", + "max_steps": 5, + "provide_run_summary": False, + "enabled": True, + "tools": [], + "managed_agents": [], + } + }, + "mcp_info": [], + } + + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr( + "agents/simple/agent.json", + json.dumps(payload).encode("utf-8"), + ) + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + names = zf.namelist() + mock_import = AsyncMock(return_value={"agent_id": 2}) + with patch( + "backend.services.agent_service.import_agent_impl", + mock_import, + ): + item = await _import_single_agent_from_zip( + zf, names, "agents/simple", mock_authorization + ) + + assert item["name"] == "simple_agent" + assert item["display_name"] == "Simple Agent" + assert item["success"] is True + mock_import.assert_called_once() + + @pytest.mark.asyncio + async def test_import_missing_agent_json(self, mock_authorization): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("agents/test/some_other_file.txt", "content") + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + names = zf.namelist() + item = await _import_single_agent_from_zip( + zf, names, "agents/test", mock_authorization + ) + + assert item["success"] is False + assert "agent.json not found" in item["error"] + assert item["name"] == "agents/test" + + @pytest.mark.asyncio + async def test_import_with_invalid_payload(self, mock_authorization): + buffer = io.BytesIO() + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr( + "agents/bad/agent.json", + '{"invalid": "payload"}'.encode("utf-8"), + ) + + buffer.seek(0) + with zipfile.ZipFile(buffer, "r") as zf: + names = zf.namelist() + with pytest.raises(Exception): + await _import_single_agent_from_zip( + zf, names, "agents/bad", mock_authorization + ) + + +# ============================================================================= +# Tests for export_agents_batch_impl +# ============================================================================= + + +class TestExportAgentsBatchImpl: + + @pytest.mark.asyncio + async def test_export_single_agent(self, mock_authorization, mock_user_info, sample_export_result): + mock_export = AsyncMock(return_value=sample_export_result) + with patch( + "backend.services.agent_service.get_current_user_info", + return_value=mock_user_info, + ), patch( + "backend.services.agent_service.search_agent_info_by_agent_id", + return_value={ + "agent_id": 1, + "name": "test_agent", + "display_name": "Test Agent", + }, + ), patch( + "backend.services.agent_service.export_agent_with_skills_impl", + mock_export, + ): + result = await export_agents_batch_impl([1], mock_authorization) + + assert result["_zip"] is True + assert "data" in result + assert result["filename"] == "agents_batch_export.zip" + + zip_bytes = result["data"] + with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as zf: + names = zf.namelist() + assert "manifest.json" in names + manifest = json.loads(zf.read("manifest.json").decode("utf-8")) + assert len(manifest["agents"]) == 1 + assert manifest["agents"][0]["agent_id"] == 1 + + @pytest.mark.asyncio + async def test_export_multiple_agents(self, mock_authorization, mock_user_info, sample_export_result): + mock_export = AsyncMock(return_value=sample_export_result) + with patch( + "backend.services.agent_service.get_current_user_info", + return_value=mock_user_info, + ), patch( + "backend.services.agent_service.search_agent_info_by_agent_id", + side_effect=[ + {"agent_id": 1, "name": "agent_one", "display_name": "Agent One"}, + {"agent_id": 2, "name": "agent_two", "display_name": "Agent Two"}, + ], + ), patch( + "backend.services.agent_service.export_agent_with_skills_impl", + mock_export, + ): + result = await export_agents_batch_impl([1, 2], mock_authorization) + + zip_bytes = result["data"] + with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as zf: + manifest = json.loads(zf.read("manifest.json").decode("utf-8")) + assert len(manifest["agents"]) == 2 + + @pytest.mark.asyncio + async def test_export_agent_not_found(self, mock_authorization, mock_user_info): + with patch( + "backend.services.agent_service.get_current_user_info", + return_value=mock_user_info, + ), patch( + "backend.services.agent_service.search_agent_info_by_agent_id", + return_value=None, + ): + result = await export_agents_batch_impl([999], mock_authorization) + + zip_bytes = result["data"] + with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as zf: + manifest = json.loads(zf.read("manifest.json").decode("utf-8")) + assert len(manifest["agents"]) == 0 + + @pytest.mark.asyncio + async def test_export_empty_agent_list(self, mock_authorization, mock_user_info): + with patch( + "backend.services.agent_service.get_current_user_info", + return_value=mock_user_info, + ): + result = await export_agents_batch_impl([], mock_authorization) + + assert result["_zip"] is True + zip_bytes = result["data"] + with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as zf: + manifest = json.loads(zf.read("manifest.json").decode("utf-8")) + assert len(manifest["agents"]) == 0 + + @pytest.mark.asyncio + async def test_export_with_special_characters_in_name(self, mock_authorization, mock_user_info, sample_export_result): + mock_export = AsyncMock(return_value=sample_export_result) + with patch( + "backend.services.agent_service.get_current_user_info", + return_value=mock_user_info, + ), patch( + "backend.services.agent_service.search_agent_info_by_agent_id", + return_value={ + "agent_id": 1, + "name": "My Agent!@#", + "display_name": "Test", + }, + ), patch( + "backend.services.agent_service.export_agent_with_skills_impl", + mock_export, + ): + result = await export_agents_batch_impl([1], mock_authorization) + + zip_bytes = result["data"] + with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as zf: + manifest = json.loads(zf.read("manifest.json").decode("utf-8")) + folder = manifest["agents"][0]["folder"] + assert "My_Agent" in folder + + @pytest.mark.asyncio + async def test_export_with_duplicate_names(self, mock_authorization, mock_user_info, sample_export_result): + mock_export = AsyncMock(return_value=sample_export_result) + with patch( + "backend.services.agent_service.get_current_user_info", + return_value=mock_user_info, + ), patch( + "backend.services.agent_service.search_agent_info_by_agent_id", + side_effect=[ + {"agent_id": 1, "name": "same_name", "display_name": "First"}, + {"agent_id": 2, "name": "same_name", "display_name": "Second"}, + ], + ), patch( + "backend.services.agent_service.export_agent_with_skills_impl", + mock_export, + ): + result = await export_agents_batch_impl([1, 2], mock_authorization) + + zip_bytes = result["data"] + with zipfile.ZipFile(io.BytesIO(zip_bytes), "r") as zf: + manifest = json.loads(zf.read("manifest.json").decode("utf-8")) + folders = [a["folder"] for a in manifest["agents"]] + assert len(folders) == 2 + assert folders[0] != folders[1] + + +# ============================================================================= +# Tests for import_agents_batch_impl +# ============================================================================= + + +class TestImportAgentsBatchImpl: + + def _create_batch_zip(self, agents_data, include_manifest=True): + buffer = io.BytesIO() + manifest_agents = [] + + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + for idx, (folder_name, payload) in enumerate(agents_data.items()): + folder = f"agents/{folder_name}" + json_payload = {k: v for k, v in payload.items() if k != "_skills"} + zf.writestr( + f"{folder}/agent.json", + json.dumps(json_payload).encode("utf-8"), + ) + if payload.get("_skills"): + for skill_name, skill_data in payload["_skills"].items(): + zf.writestr( + f"{folder}/skills/{skill_name}.zip", + skill_data, + ) + manifest_agents.append({ + "folder": folder, + "agent_id": payload["agent_id"], + "name": payload["agent_info"][str(payload["agent_id"])]["name"], + "display_name": payload["agent_info"][str(payload["agent_id"])].get("display_name"), + }) + + if include_manifest: + manifest = { + "version": "1.0", + "exported_at": "", + "agents": manifest_agents, + } + zf.writestr("manifest.json", json.dumps(manifest)) + + return buffer.getvalue() + + @pytest.mark.asyncio + async def test_import_single_agent_success(self, mock_authorization): + agents_data = { + "test_agent": { + "agent_id": 1, + "agent_info": { + "1": { + "agent_id": 1, + "tenant_id": "test_tenant", + "name": "test_agent", + "display_name": "Test Agent", + "description": "desc", + "business_description": "", + "author": "author", + "max_steps": 10, + "provide_run_summary": False, + "enabled": True, + "tools": [], + "managed_agents": [], + } + }, + "mcp_info": [], + } + } + + zip_bytes = self._create_batch_zip(agents_data) + + mock_import = AsyncMock(return_value={"agent_id": 1}) + with patch( + "backend.services.agent_service.import_agent_impl", + mock_import, + ): + result = await import_agents_batch_impl(zip_bytes, mock_authorization) + + assert result["total"] == 1 + assert result["success_count"] == 1 + assert result["failed_count"] == 0 + assert len(result["items"]) == 1 + assert result["items"][0]["success"] is True + + @pytest.mark.asyncio + async def test_import_multiple_agents_success(self, mock_authorization): + agents_data = { + "agent_one": { + "agent_id": 1, + "agent_info": { + "1": { + "agent_id": 1, + "tenant_id": "test_tenant", + "name": "agent_one", + "display_name": "Agent One", + "description": "desc", + "business_description": "", + "author": "author", + "max_steps": 10, + "provide_run_summary": False, + "enabled": True, + "tools": [], + "managed_agents": [], + } + }, + "mcp_info": [], + }, + "agent_two": { + "agent_id": 2, + "agent_info": { + "2": { + "agent_id": 2, + "tenant_id": "test_tenant", + "name": "agent_two", + "display_name": "Agent Two", + "description": "desc", + "business_description": "", + "author": "author", + "max_steps": 5, + "provide_run_summary": False, + "enabled": True, + "tools": [], + "managed_agents": [], + } + }, + "mcp_info": [], + }, + } + + zip_bytes = self._create_batch_zip(agents_data) + + mock_import = AsyncMock(return_value={"agent_id": 1}) + with patch( + "backend.services.agent_service.import_agent_impl", + mock_import, + ): + result = await import_agents_batch_impl(zip_bytes, mock_authorization) + + assert result["total"] == 2 + assert result["success_count"] == 2 + assert result["failed_count"] == 0 + + @pytest.mark.asyncio + async def test_import_with_skills(self, mock_authorization): + skill_data = b"fake_skill_zip_data" + agents_data = { + "agent_with_skill": { + "agent_id": 1, + "agent_info": { + "1": { + "agent_id": 1, + "tenant_id": "test_tenant", + "name": "agent_with_skill", + "display_name": "Agent With Skill", + "description": "desc", + "business_description": "", + "author": "author", + "max_steps": 10, + "provide_run_summary": False, + "enabled": True, + "tools": [], + "managed_agents": [], + } + }, + "mcp_info": [], + "_skills": {"my_skill": skill_data}, + } + } + + zip_bytes = self._create_batch_zip(agents_data) + + mock_import = AsyncMock(return_value={"agent_id": 1}) + with patch( + "backend.services.agent_service.import_agent_with_skills_impl", + mock_import, + ): + result = await import_agents_batch_impl(zip_bytes, mock_authorization) + + assert result["total"] == 1 + assert result["success_count"] == 1 + mock_import.assert_called_once() + + @pytest.mark.asyncio + async def test_import_invalid_zip_file(self, mock_authorization): + with pytest.raises(ValueError, match="Invalid batch export ZIP file"): + await import_agents_batch_impl(b"not a zip file", mock_authorization) + + @pytest.mark.asyncio + async def test_import_with_skill_duplicate_error(self, mock_authorization): + agents_data = { + "agent_with_skill": { + "agent_id": 1, + "agent_info": { + "1": { + "agent_id": 1, + "tenant_id": "test_tenant", + "name": "agent_with_skill", + "display_name": "Agent With Skill", + "description": "desc", + "business_description": "", + "author": "author", + "max_steps": 10, + "provide_run_summary": False, + "enabled": True, + "tools": [], + "managed_agents": [], + } + }, + "mcp_info": [], + "_skills": {"dup_skill": b"skill_data"}, + } + } + + zip_bytes = self._create_batch_zip(agents_data) + + mock_import = AsyncMock( + side_effect=SkillDuplicateError(["dup_skill"]) + ) + with patch( + "backend.services.agent_service.import_agent_with_skills_impl", + mock_import, + ): + result = await import_agents_batch_impl(zip_bytes, mock_authorization) + + assert result["total"] == 1 + assert result["success_count"] == 0 + assert result["failed_count"] == 1 + assert result["items"][0]["success"] is False + assert "Skill name conflict" in result["items"][0]["error"] + + @pytest.mark.asyncio + async def test_import_with_general_exception(self, mock_authorization): + agents_data = { + "failing_agent": { + "agent_id": 1, + "agent_info": { + "1": { + "agent_id": 1, + "tenant_id": "test_tenant", + "name": "failing_agent", + "display_name": "Failing Agent", + "description": "desc", + "business_description": "", + "author": "author", + "max_steps": 10, + "provide_run_summary": False, + "enabled": True, + "tools": [], + "managed_agents": [], + } + }, + "mcp_info": [], + } + } + + zip_bytes = self._create_batch_zip(agents_data) + + mock_import = AsyncMock(side_effect=Exception("Import failed")) + with patch( + "backend.services.agent_service.import_agent_impl", + mock_import, + ): + result = await import_agents_batch_impl(zip_bytes, mock_authorization) + + assert result["total"] == 1 + assert result["success_count"] == 0 + assert result["failed_count"] == 1 + assert result["items"][0]["success"] is False + assert result["items"][0]["error"] == "Import failed" + + @pytest.mark.asyncio + async def test_import_partial_success(self, mock_authorization): + agents_data = { + "good_agent": { + "agent_id": 1, + "agent_info": { + "1": { + "agent_id": 1, + "tenant_id": "test_tenant", + "name": "good_agent", + "display_name": "Good Agent", + "description": "desc", + "business_description": "", + "author": "author", + "max_steps": 10, + "provide_run_summary": False, + "enabled": True, + "tools": [], + "managed_agents": [], + } + }, + "mcp_info": [], + }, + "bad_agent": { + "agent_id": 2, + "agent_info": { + "2": { + "agent_id": 2, + "tenant_id": "test_tenant", + "name": "bad_agent", + "display_name": "Bad Agent", + "description": "desc", + "business_description": "", + "author": "author", + "max_steps": 5, + "provide_run_summary": False, + "enabled": True, + "tools": [], + "managed_agents": [], + } + }, + "mcp_info": [], + }, + } + + zip_bytes = self._create_batch_zip(agents_data) + + mock_import = AsyncMock( + side_effect=[ + {"agent_id": 1}, + Exception("Import failed for agent 2"), + ] + ) + with patch( + "backend.services.agent_service.import_agent_impl", + mock_import, + ): + result = await import_agents_batch_impl(zip_bytes, mock_authorization) + + assert result["total"] == 2 + assert result["success_count"] == 1 + assert result["failed_count"] == 1 + + @pytest.mark.asyncio + async def test_import_empty_batch(self, mock_authorization): + agents_data = {} + zip_bytes = self._create_batch_zip(agents_data) + + result = await import_agents_batch_impl(zip_bytes, mock_authorization) + + assert result["total"] == 0 + assert result["success_count"] == 0 + assert result["failed_count"] == 0 + assert result["items"] == [] + + @pytest.mark.asyncio + async def test_import_fallback_to_folder_scanning(self, mock_authorization): + agents_data = { + "test_agent": { + "agent_id": 1, + "agent_info": { + "1": { + "agent_id": 1, + "tenant_id": "test_tenant", + "name": "test_agent", + "display_name": "Test Agent", + "description": "desc", + "business_description": "", + "author": "author", + "max_steps": 10, + "provide_run_summary": False, + "enabled": True, + "tools": [], + "managed_agents": [], + } + }, + "mcp_info": [], + } + } + + zip_bytes = self._create_batch_zip(agents_data, include_manifest=False) + + mock_import = AsyncMock(return_value={"agent_id": 1}) + with patch( + "backend.services.agent_service.import_agent_impl", + mock_import, + ): + result = await import_agents_batch_impl(zip_bytes, mock_authorization) + + assert result["total"] == 1 + assert result["success_count"] == 1 + + @pytest.mark.asyncio + async def test_import_agent_with_missing_agent_json(self, mock_authorization): + buffer = io.BytesIO() + manifest = { + "version": "1.0", + "exported_at": "", + "agents": [ + { + "folder": "agents/test_agent", + "agent_id": 1, + "name": "test_agent", + "display_name": "Test Agent", + } + ], + } + with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as zf: + zf.writestr("manifest.json", json.dumps(manifest)) + zf.writestr("agents/test_agent/some_other_file.txt", "content") + + zip_bytes = buffer.getvalue() + + result = await import_agents_batch_impl(zip_bytes, mock_authorization) + + assert result["total"] == 1 + assert result["success_count"] == 0 + assert result["failed_count"] == 1 + assert len(result["items"]) == 1 + assert result["items"][0]["success"] is False \ No newline at end of file diff --git a/test/backend/services/test_agent_service.py b/test/backend/services/test_agent_service.py index 114e8ab0d..ece0ab88a 100644 --- a/test/backend/services/test_agent_service.py +++ b/test/backend/services/test_agent_service.py @@ -11636,7 +11636,8 @@ async def test_cleanup_channel_later(): from backend.services.agent_service import _cleanup_channel_later from backend.services.agent_service import streaming_channel_manager - with patch.object(streaming_channel_manager, 'remove_channel', new_callable=AsyncMock) as mock_remove: + with patch("backend.services.agent_service.asyncio.sleep", new=AsyncMock()), \ + patch.object(streaming_channel_manager, 'remove_channel', new_callable=AsyncMock) as mock_remove: await _cleanup_channel_later(conversation_id=123, user_id="user1", delay=0.01) mock_remove.assert_called_once_with(123, "user1")