From 910d5527cddadda4a5ac0372869d68b841ab669f Mon Sep 17 00:00:00 2001 From: dongjibao2001 <332722455@qq.com> Date: Fri, 13 Nov 2026 14:28:27 +0800 Subject: [PATCH 1/2] bugfix:workbench lazy resources --- .../references/components/resource-picker.md | 7 + backend/management/api/skill/skill.py | 12 +- backend/management/db/skill/repository.py | 18 +- backend/management/services/skill/service.py | 17 +- .../[locale]/newchat/assistant-ui/chat.tsx | 3 +- .../newchat/assistant-ui/composer.tsx | 18 +- .../conversation-knowledge-scope-modal.tsx | 44 +++-- frontend/features/workbench/WorkbenchPage.tsx | 76 +++++++-- .../workbench/components/AgentPicker.tsx | 60 ++++++- .../workbench/components/SkillPicker.tsx | 4 +- .../components/WorkbenchLoadingBoundary.tsx | 16 +- .../features/workbench/conversationRestore.ts | 29 ++-- .../features/workbench/knowledgeDisplay.ts | 11 +- .../providers/WorkbenchSessionProvider.tsx | 5 +- frontend/features/workbench/types.ts | 4 + frontend/hooks/agent/usePublishedAgentList.ts | 157 ++++++++++-------- frontend/services/agentConfigService.ts | 13 +- frontend/services/skillService.ts | 8 +- frontend/tests/workbench/AgentPicker.test.tsx | 40 +++++ .../workbench/ConversationRestore.test.ts | 45 +++-- .../tests/workbench/KnowledgeDisplay.test.ts | 18 ++ .../tests/workbench/KnowledgePicker.test.tsx | 31 +++- .../tests/workbench/SkillMountListApi.test.ts | 61 +++++++ frontend/tests/workbench/SkillPicker.test.tsx | 25 +++ .../WorkbenchBootstrapLoading.test.tsx | 8 +- .../WorkbenchLoadingBoundary.test.tsx | 76 ++------- .../workbench/WorkbenchPageLifecycle.test.tsx | 14 ++ frontend/types/skill.ts | 4 + .../cases/WB-RESOURCE-LAZY-D4-001/case.yaml | 59 +++++++ .../WB-RESOURCE-LAZY-D4-001/execution.yaml | 16 ++ .../WB-RESOURCE-LAZY-D4-001/test.spec.ts | 156 +++++++++++++++++ .../cases/WB-SKILL-SUMMARY-D2-001/case.yaml | 71 ++++++++ .../WB-SKILL-SUMMARY-D2-001/execution.yaml | 15 ++ .../cases/WB-SKILL-SUMMARY-D2-001/test.py | 95 +++++++++++ .../bugs/WB-RESOURCE-LAZY-20261113.yaml | 40 +++++ .../bugs/WB-SKILL-SUMMARY-20261113.yaml | 55 ++++++ test-e2e/features/WB-CONTROLS/cases.md | 2 + test-e2e/features/WB-CONTROLS/feature.yaml | 12 +- .../services/test_skill_mount_summary.py | 133 +++++++++++++++ 39 files changed, 1252 insertions(+), 226 deletions(-) create mode 100644 frontend/tests/workbench/SkillMountListApi.test.ts create mode 100644 test-e2e/cases/WB-RESOURCE-LAZY-D4-001/case.yaml create mode 100644 test-e2e/cases/WB-RESOURCE-LAZY-D4-001/execution.yaml create mode 100644 test-e2e/cases/WB-RESOURCE-LAZY-D4-001/test.spec.ts create mode 100644 test-e2e/cases/WB-SKILL-SUMMARY-D2-001/case.yaml create mode 100644 test-e2e/cases/WB-SKILL-SUMMARY-D2-001/execution.yaml create mode 100644 test-e2e/cases/WB-SKILL-SUMMARY-D2-001/test.py create mode 100644 test-e2e/changes/bugs/WB-RESOURCE-LAZY-20261113.yaml create mode 100644 test-e2e/changes/bugs/WB-SKILL-SUMMARY-20261113.yaml create mode 100644 test/backend/services/test_skill_mount_summary.py diff --git a/.agents/skills/nexent-frontend/references/components/resource-picker.md b/.agents/skills/nexent-frontend/references/components/resource-picker.md index f2fa6d6e3b..42a59ca24b 100644 --- a/.agents/skills/nexent-frontend/references/components/resource-picker.md +++ b/.agents/skills/nexent-frontend/references/components/resource-picker.md @@ -57,3 +57,10 @@ Workbench passes standard onRefresh/onSelectAll/allSelected/partiallySelected to ## Workbench footer alignment The standard Workbench picker path uses ResourceDrawer's default Close/Confirm labels and footer buttons. It explicitly clears footerLeading, closeText and confirmText after spreading picker options; do not reintroduce Workbench-specific Cancel/Confirm-selection labels or footer settings/default actions. Workbench Knowledge hides retrieval/default controls in drawer presentation while retaining saved retrieval_config; the legacy knowledge Modal still owns its existing controls. SkillPicker no longer exposes loadDefaults or restore state, and WorkbenchPage supplies only the existing committed mounts. Resource pagination defaults to 10 and offers exactly 10/20/50; actual slicing and repository requests must use the controlled value. Removing a UI action does not reset saved settings or change confirmation/cancellation semantics. + + +## Workbench discovery lifecycle + +Workbench renders the composer independently of candidate catalogs and execution-readiness requests. AgentPicker owns an open/active-Mine-gated, identity-scoped query when candidates are not supplied; externally supplied candidates remain supported. Repository queries require the Repository tab. Hydrate saved selection only when initial candidates arrive, without replacing an edited draft on refresh; identity changes discard the prior identity's candidate draft. Load knowledge capabilities and groups on drawer opening, keep capability/catalog errors local, and disable confirmation until required reads settle. Saved-resource validation uses pinned Agent previews and Skill detail-by-ID reads, not full candidate catalogs. Name-only knowledge restoration may use ID placeholders until explicit discovery. Sending still requires execution readiness; discovery must never unmount the composer or submit a message. + +SkillPicker opts into GET /skills?view=mount on both normal loading and post-install refresh. This projection retains display/permission fields and configuration schema/defaults, omits content and tool_ids, and must not change the default full list used by editors. Confirmation still sends only skill_id and user config_values; execution resolves authoritative content and tools on the backend. diff --git a/backend/management/api/skill/skill.py b/backend/management/api/skill/skill.py index cf21e5bfe5..19c61ae59d 100644 --- a/backend/management/api/skill/skill.py +++ b/backend/management/api/skill/skill.py @@ -6,7 +6,7 @@ from contracts.skill import InstallSkillsRequest import logging from http import HTTPStatus -from typing import Any, Dict, Optional +from typing import Any, Dict, Literal, Optional from fastapi import APIRouter, File, Form, Header, HTTPException, Query, Request, UploadFile from starlette.responses import JSONResponse @@ -62,6 +62,9 @@ async def list_skills( tenant_id: Optional[str] = Query(None, description="Tenant ID for super admin to query specific tenant's skills"), authorization: Optional[str] = Header(None), request: Request = None, + view: Literal["full", "mount"] = Query( + "full", description="Use mount for metadata without Skill content or tool associations" + ), ) -> JSONResponse: """List all available skills for the current tenant (or a specific tenant for super admin).""" try: @@ -70,13 +73,18 @@ async def list_skills( inventory_tenant = resource_read_tenant(request, shared=True) effective_tenant_id = inventory_tenant or tenant_id or current_tenant_id service = SkillService(tenant_id=effective_tenant_id) + list_options = {"for_mount": True} if view == "mount" else {} if inventory_tenant: fields = ("skill_id", "name", "description", "source", "tags", "update_time") - skills = [{field: skill.get(field) for field in fields} for skill in service.list_skills()] + skills = [ + {field: skill.get(field) for field in fields} + for skill in service.list_skills(**list_options) + ] return JSONResponse(content={"skills": skills}) skills = service.list_visible_skills( tenant_id=effective_tenant_id, user_id=user_id, + **list_options, ) return JSONResponse(content={"skills": skills}) except HTTPException: diff --git a/backend/management/db/skill/repository.py b/backend/management/db/skill/repository.py index bb72ead723..5334047008 100644 --- a/backend/management/db/skill/repository.py +++ b/backend/management/db/skill/repository.py @@ -12,6 +12,7 @@ from typing import Any, Dict, List, Optional from sqlalchemy import or_, update as sa_update +from sqlalchemy.orm import defer from contracts.constants.constants import ASSET_OWNER_TENANT_ID from management.db.client import get_db_session @@ -332,7 +333,7 @@ def _replace_skill_tool_relations( ) -def _to_dict(skill: SkillInfo) -> Dict[str, Any]: +def _to_dict(skill: SkillInfo, *, include_content: bool = True) -> Dict[str, Any]: """Convert SkillInfo to dict.""" return { "skill_id": skill.skill_id, @@ -340,7 +341,7 @@ def _to_dict(skill: SkillInfo) -> Dict[str, Any]: "tenant_id": skill.tenant_id, "description": skill.skill_description, "tags": _normalize_skill_tags(skill.skill_tags), - "content": skill.skill_content or "", + **({"content": skill.skill_content or ""} if include_content else {}), "config_schemas": skill.config_schemas, "config_values": skill.config_values, "source": skill.source, @@ -386,6 +387,19 @@ def list_skills(tenant_id: str) -> List[Dict[str, Any]]: return results +def list_skill_mount_summaries(tenant_id: str) -> List[Dict[str, Any]]: + """Read mount metadata without loading Skill bodies or tool relations.""" + with get_db_session() as session: + skills = ( + session.query(SkillInfo) + .options(defer(SkillInfo.skill_content)) + .filter(SkillInfo.tenant_id == tenant_id, SkillInfo.delete_flag != "Y") + .order_by(SkillInfo.create_time.desc(), SkillInfo.skill_id.desc()) + .all() + ) + return [_to_dict(skill, include_content=False) for skill in skills] + + def list_skill_permission_summaries(tenant_id: str) -> List[Dict[str, Any]]: """List only the fields required to resolve skill visibility and ownership.""" with get_db_session() as session: diff --git a/backend/management/services/skill/service.py b/backend/management/services/skill/service.py index b8273bece3..3d2e490b74 100644 --- a/backend/management/services/skill/service.py +++ b/backend/management/services/skill/service.py @@ -309,11 +309,14 @@ def _enrich_configs_from_yaml(self, skill: Dict[str, Any]) -> Dict[str, Any]: out.pop("config_schemas", None) return out - def list_skills(self, tenant_id: Optional[str] = None) -> List[Dict[str, Any]]: + def list_skills( + self, tenant_id: Optional[str] = None, *, for_mount: bool = False + ) -> List[Dict[str, Any]]: """List all skills for a tenant. Args: tenant_id: Tenant ID for filtering skills. Uses instance tenant_id if not provided. + for_mount: Omit Skill content and tool relations while retaining configuration metadata. Returns: List of skill info dicts @@ -322,7 +325,10 @@ def list_skills(self, tenant_id: Optional[str] = None) -> List[Dict[str, Any]]: if not effective_tenant_id: raise SkillException("tenant_id is required") try: - skills = skill_db.list_skills(effective_tenant_id) + skills = ( + skill_db.list_skill_mount_summaries(effective_tenant_id) + if for_mount else skill_db.list_skills(effective_tenant_id) + ) enriched = [self._enrich_configs_from_yaml(s) for s in skills] return enriched except Exception as e: @@ -334,13 +340,18 @@ def list_visible_skills( *, tenant_id: Optional[str] = None, user_id: str, + for_mount: bool = False, ) -> List[Dict[str, Any]]: """List skills visible to a user and attach the resolved permission.""" user_role = _get_user_role(user_id) user_group_ids = set(query_group_ids_by_user(user_id) or []) + skills = ( + self.list_skills(tenant_id=tenant_id, for_mount=True) + if for_mount else self.list_skills(tenant_id=tenant_id) + ) visible_skills = [ skill - for skill in self.list_skills(tenant_id=tenant_id) + for skill in skills if can_view_skill( skill=skill, user_id=user_id, diff --git a/frontend/app/[locale]/newchat/assistant-ui/chat.tsx b/frontend/app/[locale]/newchat/assistant-ui/chat.tsx index e1c5f5038a..98296fde93 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/chat.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/chat.tsx @@ -2,7 +2,6 @@ "use client"; - import type { FC, ReactNode } from "react"; import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; @@ -132,7 +131,7 @@ export const Chat: FC = ({ ); if (!selectedAgent) { - if (isLoadingAgents) { + if (isLoadingAgents && !workbenchPresentation && !landingContent) { return ; } if (workbenchPresentation || landingContent) { diff --git a/frontend/app/[locale]/newchat/assistant-ui/composer.tsx b/frontend/app/[locale]/newchat/assistant-ui/composer.tsx index c898cf7915..72a1e3441d 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/composer.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/composer.tsx @@ -2,9 +2,9 @@ "use client"; - import { useMemo, + useEffect, useState, useSyncExternalStore, type FC, @@ -380,6 +380,11 @@ export const Composer: FC = ({ ? { key: knowledgeAccessKey, open } : current ); + const onKnowledgeOpenChange = workbenchPresentation?.onKnowledgeOpenChange; + useEffect(() => { + onKnowledgeOpenChange?.(knowledgeModalOpen); + return () => onKnowledgeOpenChange?.(false); + }, [knowledgeModalOpen, onKnowledgeOpenChange]); const { data: knowledgeBases = [] } = useComposerKnowledgeBases( conversationResources && canSelectKnowledge, enableAidpKnowledge @@ -833,6 +838,17 @@ export const Composer: FC = ({ open={knowledgeModalOpen} value={knowledgeScope} capabilities={knowledgeCapabilities} + onRetryCapabilities={workbenchPresentation?.onKnowledgeRetry} + capabilitiesPending={ + Boolean( + workbenchPresentation?.onKnowledgeOpenChange && + !knowledgeCapabilities && + !workbenchPresentation?.knowledgeUnavailable + ) || workbenchPresentation?.knowledgePending + } + capabilitiesUnavailable={ + workbenchPresentation?.knowledgeUnavailable + } onCancel={() => setKnowledgeModalOpen(false)} onConfirm={async (scope, preview) => { if (!canSelectKnowledge) return; diff --git a/frontend/app/[locale]/newchat/assistant-ui/conversation-knowledge-scope-modal.tsx b/frontend/app/[locale]/newchat/assistant-ui/conversation-knowledge-scope-modal.tsx index 9cbe5f998a..8773f6c64e 100644 --- a/frontend/app/[locale]/newchat/assistant-ui/conversation-knowledge-scope-modal.tsx +++ b/frontend/app/[locale]/newchat/assistant-ui/conversation-knowledge-scope-modal.tsx @@ -47,6 +47,9 @@ interface ConversationKnowledgeScopeModalProps { disabled?: boolean; value: ConversationKnowledgeScope | null; capabilities: KnowledgeCapabilities | null; + onRetryCapabilities?: () => void; + capabilitiesPending?: boolean; + capabilitiesUnavailable?: boolean; onCancel: () => void; onConfirm: ( scope: ConversationKnowledgeScope, @@ -114,6 +117,9 @@ export const ConversationKnowledgeScopeModal: FC< open, value, capabilities, + onRetryCapabilities, + capabilitiesPending = false, + capabilitiesUnavailable = false, onCancel, onConfirm, presentation = "modal", @@ -123,7 +129,9 @@ export const ConversationKnowledgeScopeModal: FC< const router = useRouter(); const { enableAidpKnowledge, isDeploymentReady } = useDeployment(); const { user } = useAuthorizationContext(); - const { data: groupListData } = useGroupList(user?.tenantId ?? null); + const { data: groupListData } = useGroupList( + open ? (user?.tenantId ?? null) : null + ); const groupNameById = useMemo( () => new Map( @@ -149,6 +157,7 @@ export const ConversationKnowledgeScopeModal: FC< [] ); const [listError, setListError] = useState(false); + const resourceError = listError || capabilitiesUnavailable; const [saving, setSaving] = useState(false); const [initialSelectedIds, setInitialSelectedIds] = useState([]); const [initialSelectionWasFiltered, setInitialSelectionWasFiltered] = @@ -179,8 +188,10 @@ export const ConversationKnowledgeScopeModal: FC< >(null); const loading = open && - Boolean(configuredSource) && - settledCatalogRequest !== catalogLoadRequest; + !capabilitiesUnavailable && + (capabilitiesPending || + (Boolean(configuredSource) && + settledCatalogRequest !== catalogLoadRequest)); const [previousInputs, setPreviousInputs] = useState({ open, search, @@ -217,7 +228,13 @@ export const ConversationKnowledgeScopeModal: FC< useEffect(() => { const { catalogRequest } = catalogLoadRequest; const configuredSource = catalogRequest.source; - if (!open || !configuredSource) return; + if ( + !open || + !configuredSource || + capabilitiesPending || + capabilitiesUnavailable + ) + return; const { value, capabilities, t } = catalogRequest; const normalized = normalizeScopeForSource(value, configuredSource); let cancelled = false; @@ -316,7 +333,7 @@ export const ConversationKnowledgeScopeModal: FC< return () => { cancelled = true; }; - }, [open, catalogLoadRequest]); + }, [open, catalogLoadRequest, capabilitiesPending, capabilitiesUnavailable]); const localOptions = useMemo( () => @@ -472,7 +489,7 @@ export const ConversationKnowledgeScopeModal: FC< }; const handleConfirm = async () => { - if (!configuredSource || loading || saving || listError) return; + if (!configuredSource || loading || saving || resourceError) return; const selectedIds = configuredSource === "local" ? draft.local.knowledge_ids @@ -708,7 +725,7 @@ export const ConversationKnowledgeScopeModal: FC< pageOnly={false} allSelected={allSelected} hasSelection={values.length > 0} - disabled={loading || saving || listError} + disabled={loading || saving || resourceError} empty={selectableIds.length === 0} onToggleAll={handleSelectAll} onClear={() => updateSelectedValues(source, [])} @@ -870,7 +887,7 @@ export const ConversationKnowledgeScopeModal: FC< const drawerFooter = (
{ if (!configuredSource) return; setDraft( @@ -913,7 +930,7 @@ export const ConversationKnowledgeScopeModal: FC< disabled={disabled} confirmDisabled={ loading || - listError || + resourceError || !source || (!!tags.predicates.length && tags.loading) } @@ -962,24 +979,25 @@ export const ConversationKnowledgeScopeModal: FC< selectAllDisabled: loading || saving || - listError || + resourceError || !drawerSelection?.selectableIds.length, onRefresh: () => { if (loading || saving) return; + if (capabilitiesUnavailable) onRetryCapabilities?.(); setListError(false); tags.retry?.(); setRefreshRevision((value) => value + 1); }, refreshDisabled: loading || saving || !source, controlsContent: - listError || + resourceError || capabilities?.legacy_prompt_warning?.detected || (value && ((value.local.mode === "override" && source === "aidp") || (value.aidp.mode === "override" && source === "local"))) ? ( <> {knowledgeWarnings} - {listError && ( + {resourceError && ( { const [chatMode, setChatMode] = useState("execution"); const [skillPickerOpen, setSkillPickerOpen] = useState(false); const [agentPickerOpen, setAgentPickerOpen] = useState(false); + const [knowledgePickerOpen, setKnowledgePickerOpen] = useState(false); + const [knowledgeRetryRevision, setKnowledgeRetryRevision] = useState(0); const [creationAgentsByThread, setCreationAgentsByThread] = useState< Record >({}); @@ -401,10 +403,37 @@ const HomeContent: FC = () => { const genericAgentVersion = workbenchBootstrap?.generic_agent?.version_no; const [knowledgeCapabilities, setKnowledgeCapabilities] = useConversationScopedState( - JSON.stringify([genericAgentId, genericAgentVersion]), + JSON.stringify([ + user?.tenantId, + user?.id, + genericAgentId, + genericAgentVersion, + ]), null ); + const [knowledgeSettled, setKnowledgeSettled] = useConversationScopedState( + JSON.stringify([ + user?.tenantId, + user?.id, + genericAgentId, + genericAgentVersion, + ]), + false + ); + const onKnowledgeOpenChange = useCallback( + (open: boolean) => { + setKnowledgePickerOpen(open); + if (open && !knowledgeCapabilities) setKnowledgeSettled(false); + }, + [knowledgeCapabilities, setKnowledgeSettled] + ); + + const onKnowledgeRetry = useCallback(() => { + setKnowledgeSettled(false); + setKnowledgeRetryRevision((revision) => revision + 1); + }, [setKnowledgeSettled]); + const [restoredLocalSnapshot, setRestoredLocalSnapshot] = useState(localSnapshot); if (restoredLocalSnapshot !== localSnapshot) { @@ -477,17 +506,23 @@ const HomeContent: FC = () => { useEffect(() => { const agentId = genericAgentId; - if (!agentId) return; + if (!knowledgePickerOpen || !agentId || knowledgeCapabilities) return; let cancelled = false; const versionNo = genericAgentVersion; void conversationService .getKnowledgeCapabilities(agentId, versionNo) .then((capabilities) => { - if (!cancelled) setKnowledgeCapabilities(capabilities); + if (!cancelled) { + setKnowledgeCapabilities(capabilities); + setKnowledgeSettled(true); + } }) .catch((error) => { - if (!cancelled) setKnowledgeCapabilities(null); + if (!cancelled) { + setKnowledgeCapabilities(null); + setKnowledgeSettled(true); + } log.error( "[HomeContent] Failed to load knowledge capabilities:", error @@ -497,7 +532,15 @@ const HomeContent: FC = () => { return () => { cancelled = true; }; - }, [genericAgentId, genericAgentVersion, setKnowledgeCapabilities]); + }, [ + knowledgePickerOpen, + knowledgeRetryRevision, + genericAgentId, + genericAgentVersion, + knowledgeCapabilities, + setKnowledgeCapabilities, + setKnowledgeSettled, + ]); useEffect(() => { const numericConversationId = Number(activeConversationId); @@ -610,7 +653,7 @@ const HomeContent: FC = () => { let restoredPreview = readKnowledgeCache(activeThreadId)?.preview ?? null; try { - restoredPreview = await restoreKnowledgeDisplay(restoredScope); + restoredPreview = await restoreKnowledgeDisplay(restoredScope, false); } catch (error) { log.warn("Knowledge display lookup failed", error); } @@ -1500,6 +1543,8 @@ const HomeContent: FC = () => { runtimeMetadata={runtimeMetadata} onRuntimeMetadataChange={handleRuntimeMetadataChange} readOnly={ + isLoadingModels || + isLoadingWorkbenchBootstrap || !workbenchSendability.canSend || (workbenchState.config.mode === "agent_create" && isAgentCreationAwaitingDraft( @@ -1514,11 +1559,11 @@ const HomeContent: FC = () => { nl2AgentComposerDisabled) } readOnlyReason={ - !isCreating && llmModels.length === 0 - ? isLoadingModels - ? t("ui.localization.loadingModels") - : t("ui.localization.noModels") - : workbenchSendability.reason + isLoadingModels || isLoadingWorkbenchBootstrap + ? t("common.loading") + : !isCreating && llmModels.length === 0 + ? t("ui.localization.noModels") + : workbenchSendability.reason } skillFiles={ workbenchState.config.mode === "skill_create" && @@ -1570,6 +1615,13 @@ const HomeContent: FC = () => { onOpenWorkbenchSkillPicker={() => setSkillPickerOpen(true)} workbenchPresentation={{ mode: workbenchState.config.mode, + onKnowledgeOpenChange, + onKnowledgeRetry, + knowledgePending: knowledgePickerOpen && !knowledgeSettled, + knowledgeUnavailable: + knowledgePickerOpen && + knowledgeSettled && + !knowledgeCapabilities, onExitCreation: () => void changeCreationMode("generic_chat"), actions: ( { String(mount.agent_id) diff --git a/frontend/features/workbench/components/AgentPicker.tsx b/frontend/features/workbench/components/AgentPicker.tsx index f6bd664a51..6c558ca498 100644 --- a/frontend/features/workbench/components/AgentPicker.tsx +++ b/frontend/features/workbench/components/AgentPicker.tsx @@ -24,6 +24,10 @@ import { fetchRepositoryImportPrecheck, importAgentFromRepository, } from "@/services/agentRepositoryService"; +import { + usePublishedAgentList, + publishedAgentQueryKey, +} from "@/hooks/agent/usePublishedAgentList"; import { fetchPublishedAgentList } from "@/services/agentConfigService"; import type { Agent } from "@/types/agentConfig"; import { RESOURCE_PAGE_SIZE, resourcePage } from "./ResourcePagination"; @@ -32,7 +36,8 @@ import { getUnavailableReasonLabels } from "@/lib/agentLabelMapper"; export function AgentPicker({ open, - agents, + agents: suppliedAgents, + identity, selectedId, selectedIds, onCancel, @@ -42,7 +47,8 @@ export function AgentPicker({ disabled = false, }: { open: boolean; - agents: Agent[]; + agents?: Agent[]; + identity?: string; selectedId?: string; selectedIds?: string[]; onCancel: () => void; @@ -51,6 +57,18 @@ export function AgentPicker({ multiple?: boolean; disabled?: boolean; }) { + const [tab, setTab] = useState("mine"); + const candidates = usePublishedAgentList({ + enabled: open && tab === "mine" && suppliedAgents === undefined, + identity, + retry: false, + }); + const agents = suppliedAgents ?? candidates.agents; + const catalogError = + tab === "mine" && suppliedAgents === undefined && candidates.isError; + const [needsHydration, setNeedsHydration] = useState( + open && suppliedAgents === undefined + ); const [draft, setDraft] = useState(() => { const ids = selectedIds ?? (selectedId ? [selectedId] : []); return open @@ -86,7 +104,7 @@ export function AgentPicker({ const [refreshRevision, setRefreshRevision] = useState(0); const queryClient = useQueryClient(); const { t } = useTranslation(); - const [tab, setTab] = useState("mine"); + const [page, setPage] = useState(1); const [total, setTotal] = useState(0); const [repository, setRepository] = useState( @@ -132,14 +150,34 @@ export function AgentPicker({ typeof repositoryRequest | null >(null); const loading = + (open && + tab === "mine" && + suppliedAgents === undefined && + candidates.isFetching) || operationLoading || (open && tab === "repository" && settledRepositoryRequest !== repositoryRequest); + if (needsHydration && candidates.isSuccess) { + setNeedsHydration(false); + const ids = selectedIds ?? (selectedId ? [selectedId] : []); + setDraft( + ids + .map((id) => agents.find((agent) => String(agent.id) === id)) + .filter((agent): agent is Agent => Boolean(agent)) + ); + } + const [previousIdentity, setPreviousIdentity] = useState(identity); + if (previousIdentity !== identity) { + setPreviousIdentity(identity); + setDraft([]); + setNeedsHydration(open && suppliedAgents === undefined); + } // Reset session state before children render, rather than after commit. if (previousOpen !== open) { setPreviousOpen(open); if (open) { + setNeedsHydration(suppliedAgents === undefined && !candidates.isSuccess); const ids = selectedIds ?? (selectedId ? [selectedId] : []); setDraft( ids @@ -248,7 +286,7 @@ export function AgentPicker({ if (!refreshed.success) throw new Error(t("ui.localization.importRefreshFailed")); const freshAgents = (refreshed.data ?? []) as Agent[]; - queryClient.setQueryData(["publishedAgentsList"], freshAgents); + queryClient.setQueryData(publishedAgentQueryKey(identity), freshAgents); const imported = freshAgents.find( (agent) => Number(agent.id) === result.agent_id ); @@ -315,6 +353,13 @@ export function AgentPicker({ spinning={loading} className="h-full [&>.ant-spin-container]:min-h-full" > + {catalogError && tab === "mine" && ( + + )} {(tab === "mine" ? visible.length : repository.length) === 0 && ( @@ -471,7 +516,10 @@ export function AgentPicker({ saving={saving} disabled={disabled} confirmDisabled={ - loading || importing || (!!tags.predicates.length && tags.loading) + loading || + catalogError || + importing || + (!!tags.predicates.length && tags.loading) } onCancel={() => { operationRef.current += 1; @@ -482,7 +530,7 @@ export function AgentPicker({ onCancel(); }} onConfirm={async () => { - if (disabled || saving || loading || importing) return; + if (disabled || saving || loading || catalogError || importing) return; if (draft.length > (multiple ? 8 : 1)) { message.warning( t("workbench.controls.agentLimit", "最多选择 8 个智能体") diff --git a/frontend/features/workbench/components/SkillPicker.tsx b/frontend/features/workbench/components/SkillPicker.tsx index 30ecb70719..33b0903d71 100644 --- a/frontend/features/workbench/components/SkillPicker.tsx +++ b/frontend/features/workbench/components/SkillPicker.tsx @@ -184,7 +184,7 @@ export function SkillPicker({ useEffect(() => { if (!open) return; let cancelled = false; - void fetchSkillsList() + void fetchSkillsList(undefined, "mount") .then((items) => { if (!cancelled) setSkills(items); }) @@ -244,7 +244,7 @@ export function SkillPicker({ if (operation !== installOperationRef.current) return; if (!result.skill_id) throw new Error(t("workbench.controls.skillInstallMissingId")); - const items = await fetchSkillsList(); + const items = await fetchSkillsList(undefined, "mount"); if (operation !== installOperationRef.current) return; setSkills(items); if (selectedIds.size >= 20) { diff --git a/frontend/features/workbench/components/WorkbenchLoadingBoundary.tsx b/frontend/features/workbench/components/WorkbenchLoadingBoundary.tsx index 201649a0e3..6c1e284187 100644 --- a/frontend/features/workbench/components/WorkbenchLoadingBoundary.tsx +++ b/frontend/features/workbench/components/WorkbenchLoadingBoundary.tsx @@ -20,12 +20,7 @@ export function WorkbenchLoadingBoundary({ children, }: WorkbenchLoadingBoundaryProps) { const { t } = useTranslation(); - if ( - !conversationReady || - modelsLoading || - bootstrapLoading || - resourcesResolving - ) { + if (!conversationReady) { return (
); } - return children; + return ( +
+ {children} +
+ ); } diff --git a/frontend/features/workbench/conversationRestore.ts b/frontend/features/workbench/conversationRestore.ts index 15933c4cb0..df8dc0a977 100644 --- a/frontend/features/workbench/conversationRestore.ts +++ b/frontend/features/workbench/conversationRestore.ts @@ -4,11 +4,11 @@ import { t as translate } from "i18next"; import { getCreatorMode } from "@/lib/creatorConversation"; import { initialWorkbenchState } from "./state"; import { previewWorkbenchAgent } from "./api"; -import { fetchSkillsList } from "@/services/skillService"; +import { fetchSkillById } from "@/services/agentConfigService"; import type { WorkbenchSessionConfig } from "./types"; import type { ConversationKnowledgeScope } from "@/types/knowledgeScope"; -/** Resolve display resources before releasing the conversation's send gate. */ +/** Validate only saved mounts before releasing the conversation send gate. */ export async function resolveRestoredWorkbench(conversation: { workbench_config?: WorkbenchSessionConfig | null; workbench_config_version?: number; @@ -26,24 +26,19 @@ export async function resolveRestoredWorkbench(conversation: { if (!config) throw new Error("This conversation does not belong to Workbench"); const version = conversation.workbench_config_version ?? 0; - await Promise.all( - config.agent_mounts.map((mount) => - previewWorkbenchAgent(mount.agent_id, mount.version_no) - ) - ); const skillNames: Record = {}; - if (config.skill_mounts.length) { - const skills = await fetchSkillsList(); - for (const mount of config.skill_mounts) { - const skill = skills.find( - (item) => Number(item.skill_id) === mount.skill_id - ); - if (!skill) + await Promise.all([ + ...config.agent_mounts.map((mount) => + previewWorkbenchAgent(mount.agent_id, mount.version_no) + ), + ...config.skill_mounts.map(async (mount) => { + const result = await fetchSkillById(mount.skill_id); + if (!result.success || !result.data) throw new Error( translate("ui.localization.conversationSkillsUnavailable") ); - skillNames[mount.skill_id] = skill.name; - } - } + skillNames[mount.skill_id] = result.data.name || `#${mount.skill_id}`; + }), + ]); return { config, version, skillNames }; } diff --git a/frontend/features/workbench/knowledgeDisplay.ts b/frontend/features/workbench/knowledgeDisplay.ts index 859bf6227d..5671f1e0d7 100644 --- a/frontend/features/workbench/knowledgeDisplay.ts +++ b/frontend/features/workbench/knowledgeDisplay.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + import knowledgeBaseService from "@/services/knowledgeBaseService"; import type { ConversationKnowledgeScope, @@ -5,14 +7,17 @@ import type { } from "@/types/knowledgeScope"; export async function restoreKnowledgeDisplay( - scope: ConversationKnowledgeScope | null + scope: ConversationKnowledgeScope | null, + lookupNames = true ): Promise { if (!scope) return null; const [local, aidp] = await Promise.all([ - scope.local.mode === "override" && scope.local.knowledge_ids.length + lookupNames && + scope.local.mode === "override" && + scope.local.knowledge_ids.length ? knowledgeBaseService.getKnowledgeBasesInfo(true, false) : null, - scope.aidp.mode === "override" && scope.aidp.kds_ids.length + lookupNames && scope.aidp.mode === "override" && scope.aidp.kds_ids.length ? knowledgeBaseService.getAidpKnowledgeBasesAll() : null, ]); diff --git a/frontend/features/workbench/providers/WorkbenchSessionProvider.tsx b/frontend/features/workbench/providers/WorkbenchSessionProvider.tsx index 8f988b15b5..5c428aaa71 100644 --- a/frontend/features/workbench/providers/WorkbenchSessionProvider.tsx +++ b/frontend/features/workbench/providers/WorkbenchSessionProvider.tsx @@ -222,7 +222,10 @@ export function WorkbenchSessionProvider({ onThreadIdChange: handleThreadIdChange, }); - const { isLoading: isLoadingAgents, agents } = usePublishedAgentList(); + const { isLoading: isLoadingAgents, agents } = usePublishedAgentList({ + enabled: false, + identity, + }); const handleWorkbenchModeChange = useCallback( (mode: "agent_create" | "skill_create" | "generic_chat") => { diff --git a/frontend/features/workbench/types.ts b/frontend/features/workbench/types.ts index d8e489ec1f..0f03c3ae27 100644 --- a/frontend/features/workbench/types.ts +++ b/frontend/features/workbench/types.ts @@ -67,5 +67,9 @@ export interface WorkbenchResourceControls { export interface WorkbenchComposerPresentation { mode: WorkbenchMode; onExitCreation: () => void; + onKnowledgeOpenChange?: (open: boolean) => void; + onKnowledgeRetry?: () => void; + knowledgePending?: boolean; + knowledgeUnavailable?: boolean; actions: import("react").ReactNode; } diff --git a/frontend/hooks/agent/usePublishedAgentList.ts b/frontend/hooks/agent/usePublishedAgentList.ts index e9a3bd6a90..700346f87f 100644 --- a/frontend/hooks/agent/usePublishedAgentList.ts +++ b/frontend/hooks/agent/usePublishedAgentList.ts @@ -1,92 +1,107 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + import { useQuery, useQueryClient } from "@tanstack/react-query"; import { fetchPublishedAgentList as fetchPublishedAgentListService } from "@/services/agentConfigService"; import { useMemo, useState, useCallback } from "react"; import { Agent } from "@/types/agentConfig"; +const EMPTY_AGENTS: Agent[] = []; + +export const publishedAgentQueryKey = (identity?: string) => + identity ? ["publishedAgentsList", identity] : ["publishedAgentsList"]; + interface UsePublishedAgentListOptions { - page?: number; - pageSize?: number; - enabled?: boolean; + identity?: string; + retry?: boolean; + page?: number; + pageSize?: number; + enabled?: boolean; } export function usePublishedAgentList({ - page = 1, - pageSize = Number.MAX_SAFE_INTEGER, - enabled = true, + page = 1, + pageSize = Number.MAX_SAFE_INTEGER, + enabled = true, + identity, + retry, }: UsePublishedAgentListOptions = {}) { - const queryClient = useQueryClient(); - const [search, setSearch] = useState(""); + const queryClient = useQueryClient(); + const [search, setSearch] = useState(""); - const query = useQuery({ - queryKey: ["publishedAgentsList"], - queryFn: async () => { - const res = await fetchPublishedAgentListService(); - if (!res || !res.success) { - throw new Error(res?.message || "Failed to fetch published agents"); - } - return (res.data || []) as Agent[]; - }, - staleTime: 60_000, - refetchOnMount: "always", - enabled, - }); + const query = useQuery({ + queryKey: publishedAgentQueryKey(identity), + queryFn: async () => { + const res = await fetchPublishedAgentListService(); + if (!res || !res.success) { + throw new Error(res?.message || "Failed to fetch published agents"); + } + return (res.data || []) as Agent[]; + }, + staleTime: 60_000, + refetchOnMount: "always", + enabled, + retry, + }); - const agents = query.data ?? []; + const agents = query.data ?? EMPTY_AGENTS; - const availableAgents = useMemo(() => { - return agents.filter((a) => a.is_available !== false); - }, [agents]); + const availableAgents = useMemo(() => { + return agents.filter((a) => a.is_available !== false); + }, [agents]); - const availableMainAgents = useMemo(() => { - return agents.filter((agent) => agent.is_available !== false && agent.is_main_agent !== false); - }, [agents]); + const availableMainAgents = useMemo(() => { + return agents.filter( + (agent) => agent.is_available !== false && agent.is_main_agent !== false + ); + }, [agents]); - const filteredAgents = useMemo(() => { - const trimmedSearch = search.trim(); - if (!trimmedSearch) { - return availableMainAgents; - } + const filteredAgents = useMemo(() => { + const trimmedSearch = search.trim(); + if (!trimmedSearch) { + return availableMainAgents; + } - const searchLower = trimmedSearch.toLowerCase(); - return availableMainAgents.filter((agent) => { - const name = agent.name?.toLowerCase() || ""; - const displayName = agent.display_name?.toLowerCase() || ""; - const description = agent.description?.toLowerCase() || ""; - const author = agent.author?.toLowerCase() || ""; - return ( - name.includes(searchLower) || - displayName.includes(searchLower) || - description.includes(searchLower) || - author.includes(searchLower) - ); - }); - }, [availableMainAgents, search]); + const searchLower = trimmedSearch.toLowerCase(); + return availableMainAgents.filter((agent) => { + const name = agent.name?.toLowerCase() || ""; + const displayName = agent.display_name?.toLowerCase() || ""; + const description = agent.description?.toLowerCase() || ""; + const author = agent.author?.toLowerCase() || ""; + return ( + name.includes(searchLower) || + displayName.includes(searchLower) || + description.includes(searchLower) || + author.includes(searchLower) + ); + }); + }, [availableMainAgents, search]); - const totalPages = useMemo(() => { - return Math.max(1, Math.ceil(filteredAgents.length / pageSize)); - }, [filteredAgents.length, pageSize]); + const totalPages = useMemo(() => { + return Math.max(1, Math.ceil(filteredAgents.length / pageSize)); + }, [filteredAgents.length, pageSize]); - const paginatedAgents = useMemo(() => { - const startIndex = (page - 1) * pageSize; - return filteredAgents.slice(startIndex, startIndex + pageSize); - }, [filteredAgents, page, pageSize]); + const paginatedAgents = useMemo(() => { + const startIndex = (page - 1) * pageSize; + return filteredAgents.slice(startIndex, startIndex + pageSize); + }, [filteredAgents, page, pageSize]); - const updateSearch = useCallback((value: string) => { - setSearch(value); - }, []); + const updateSearch = useCallback((value: string) => { + setSearch(value); + }, []); - return { - ...query, - agents, - availableAgents, - availableMainAgents, - paginatedAgents, - filteredAgents, - page, - totalPages, - pageSize, - search, - updateSearch, - invalidate: () => queryClient.invalidateQueries({ queryKey: ["publishedAgentsList"] }), - }; + return { + ...query, + agents, + availableAgents, + availableMainAgents, + paginatedAgents, + filteredAgents, + page, + totalPages, + pageSize, + search, + updateSearch, + invalidate: () => + queryClient.invalidateQueries({ queryKey: ["publishedAgentsList"] }), + }; } diff --git a/frontend/services/agentConfigService.ts b/frontend/services/agentConfigService.ts index bdcb1139c9..9fccd683e4 100644 --- a/frontend/services/agentConfigService.ts +++ b/frontend/services/agentConfigService.ts @@ -1411,11 +1411,16 @@ export const validateTool = async ( * @param tenantId - Optional tenant ID. If not provided, fetches for the current user's tenant. * @returns list of skills with skill_id, name, description, source, etc. */ -export const fetchSkills = async (tenantId?: string | null) => { +export const fetchSkills = async ( + tenantId?: string | null, + view: import("@/types/skill").SkillListView = "full" +) => { try { - const url = tenantId - ? `${API_ENDPOINTS.skills.list}?tenant_id=${encodeURIComponent(tenantId)}` - : API_ENDPOINTS.skills.list; + const params = new URLSearchParams(); + if (tenantId) params.set("tenant_id", tenantId); + if (view !== "full") params.set("view", view); + const query = params.toString(); + const url = `${API_ENDPOINTS.skills.list}${query ? `?${query}` : ""}`; const response = await fetch(url, { headers: getAuthHeaders(), }); diff --git a/frontend/services/skillService.ts b/frontend/services/skillService.ts index 4d671334c8..40d28fb96e 100644 --- a/frontend/services/skillService.ts +++ b/frontend/services/skillService.ts @@ -168,9 +168,13 @@ export const processSkillStream = async ( * @param tenantId - Optional tenant ID for super admin to query a specific tenant's skills. */ export async function fetchSkillsList( - tenantId?: string | null + tenantId?: string | null, + view: import("@/types/skill").SkillListView = "full" ): Promise { - const res = await fetchSkills(tenantId); + const res = + view === "full" + ? await fetchSkills(tenantId) + : await fetchSkills(tenantId, view); if (!res.success) { throw new Error(res.message || "Failed to fetch skills"); } diff --git a/frontend/tests/workbench/AgentPicker.test.tsx b/frontend/tests/workbench/AgentPicker.test.tsx index dd02d3bdd8..754db003ce 100644 --- a/frontend/tests/workbench/AgentPicker.test.tsx +++ b/frontend/tests/workbench/AgentPicker.test.tsx @@ -622,3 +622,43 @@ beforeEach(async () => { interpolation: { escapeValue: false }, }); }); + +it("loads owned candidates only on opening and restores delayed selected IDs", async () => { + let finish!: ( + value: Awaited> + ) => void; + vi.mocked(fetchPublishedAgentList).mockReturnValue( + new Promise((resolve) => { + finish = resolve; + }) + ); + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + const confirm = vi.fn(); + const props = { + selectedIds: ["8"], + multiple: true, + onSelect: vi.fn(), + onCancel: vi.fn(), + onConfirm: confirm, + }; + const view = (open: boolean) => ( + + + + ); + const page = render(view(false)); + expect(fetchPublishedAgentList).not.toHaveBeenCalled(); + page.rerender(view(true)); + await waitFor(() => expect(fetchPublishedAgentList).toHaveBeenCalledOnce()); + finish({ success: true, data: [agent], message: "" }); + const option = await screen.findByRole("option", { name: /Agent A/ }); + await waitFor(() => expect(option).toHaveAttribute("aria-selected", "true")); + await userEvent.click(screen.getByRole("button", { name: "确认" })); + expect(confirm).toHaveBeenCalledWith([agent]); + page.rerender(view(false)); + page.rerender(view(true)); + expect(fetchPublishedAgentList).toHaveBeenCalledOnce(); + client.clear(); +}); diff --git a/frontend/tests/workbench/ConversationRestore.test.ts b/frontend/tests/workbench/ConversationRestore.test.ts index 7f340eabcb..69aa820e32 100644 --- a/frontend/tests/workbench/ConversationRestore.test.ts +++ b/frontend/tests/workbench/ConversationRestore.test.ts @@ -3,10 +3,10 @@ import { beforeEach, expect, it, vi } from "vitest"; import { resolveRestoredWorkbench } from "@/features/workbench/conversationRestore"; import { previewWorkbenchAgent } from "@/features/workbench/api"; -import { fetchSkillsList, type SkillListItem } from "@/services/skillService"; +import { fetchSkillById } from "@/services/agentConfigService"; import { initialWorkbenchState } from "@/features/workbench/state"; vi.mock("@/features/workbench/api", () => ({ previewWorkbenchAgent: vi.fn() })); -vi.mock("@/services/skillService", () => ({ fetchSkillsList: vi.fn() })); +vi.mock("@/services/agentConfigService", () => ({ fetchSkillById: vi.fn() })); beforeEach(() => { vi.clearAllMocks(); vi.mocked(previewWorkbenchAgent).mockResolvedValue({ @@ -17,10 +17,11 @@ beforeEach(() => { { skill_id: 1, config_values: { region: "published" } }, ], }); - vi.mocked(fetchSkillsList).mockResolvedValue([ - { skill_id: "1", name: "Default" }, - { skill_id: "2", name: "Selected" }, - ] as SkillListItem[]); + vi.mocked(fetchSkillById).mockResolvedValue({ + success: true, + data: { skill_id: 2, name: "Selected" }, + message: "", + }); }); it("UT-FE-WB-032 restores canonical declarations and display names without default reintroduction", async () => { const config = { @@ -35,6 +36,7 @@ it("UT-FE-WB-032 restores canonical declarations and display names without defau agent_id: 99, }); expect(previewWorkbenchAgent).toHaveBeenCalledExactlyOnceWith(8, 3); + expect(fetchSkillById).toHaveBeenCalledExactlyOnceWith(2); expect(result).toEqual({ config, version: 7, skillNames: { 2: "Selected" } }); expect(result.config.skill_mounts).not.toContainEqual({ skill_id: 1, @@ -48,8 +50,8 @@ it("does not silently import an ordinary Agent conversation into Workbench", asy expect(previewWorkbenchAgent).not.toHaveBeenCalled(); }); it("restoration stays pending until display resources finish loading", async () => { - let finish!: (items: SkillListItem[]) => void; - vi.mocked(fetchSkillsList).mockImplementation( + let finish!: (result: Awaited>) => void; + vi.mocked(fetchSkillById).mockImplementation( () => new Promise((resolve) => { finish = resolve; @@ -62,9 +64,13 @@ it("restoration stays pending until display resources finish loading", async () skill_mounts: [{ skill_id: 1, config_values: {} }], }, }).then(ready); - await vi.waitFor(() => expect(fetchSkillsList).toHaveBeenCalledOnce()); + await vi.waitFor(() => expect(fetchSkillById).toHaveBeenCalledOnce()); expect(ready).not.toHaveBeenCalled(); - finish([{ skill_id: "1", name: "Default" }] as SkillListItem[]); + finish({ + success: true, + data: { skill_id: 1, name: "Default" }, + message: "", + }); await pending; expect(ready).toHaveBeenCalledOnce(); }); @@ -86,7 +92,7 @@ it.each([ expect(result.version).toBe(0); expect(conversation.workbench_config).toBeNull(); expect(previewWorkbenchAgent).not.toHaveBeenCalled(); - expect(fetchSkillsList).not.toHaveBeenCalled(); + expect(fetchSkillById).not.toHaveBeenCalled(); result.config.agent_mounts.push({ agent_id: 99, version_no: 1 }); expect(initialWorkbenchState.config.agent_mounts).toEqual([]); } @@ -108,3 +114,20 @@ it("retains a saved creation configuration without rewriting it", async () => { expect(config).toEqual(before); expect(result.version).toBe(7); }); + +it("rejects an unavailable saved skill without dropping or rewriting mounts", async () => { + vi.mocked(fetchSkillById).mockResolvedValue({ + success: false, + data: null, + message: "offline", + }); + const config = { + ...initialWorkbenchState.config, + skill_mounts: [{ skill_id: 2, config_values: { region: "custom" } }], + }; + const before = structuredClone(config); + await expect( + resolveRestoredWorkbench({ workbench_config: config }) + ).rejects.toThrow(); + expect(config).toEqual(before); +}); diff --git a/frontend/tests/workbench/KnowledgeDisplay.test.ts b/frontend/tests/workbench/KnowledgeDisplay.test.ts index de86a7c0a3..bb46be5ed4 100644 --- a/frontend/tests/workbench/KnowledgeDisplay.test.ts +++ b/frontend/tests/workbench/KnowledgeDisplay.test.ts @@ -1,3 +1,5 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + import { expect, it, vi } from "vitest"; import { restoreKnowledgeDisplay } from "@/features/workbench/knowledgeDisplay"; import knowledgeBaseService from "@/services/knowledgeBaseService"; @@ -23,3 +25,19 @@ it("restores names by business ID rather than list position after conversation c expect(result?.local.display_names).toEqual(["First", "Second", "#3"]); expect(knowledgeBaseService.getAidpKnowledgeBasesAll).not.toHaveBeenCalled(); }); + +it("restores saved IDs without querying unopened catalogs", async () => { + vi.clearAllMocks(); + const result = await restoreKnowledgeDisplay( + { + schema_version: 1, + local: { mode: "override", knowledge_ids: ["2", "1"] }, + aidp: { mode: "override", kds_ids: ["kds-a"] }, + }, + false + ); + expect(result?.local.display_names).toEqual(["#2", "#1"]); + expect(result?.aidp.kds_ids).toEqual(["kds-a"]); + expect(knowledgeBaseService.getKnowledgeBasesInfo).not.toHaveBeenCalled(); + expect(knowledgeBaseService.getAidpKnowledgeBasesAll).not.toHaveBeenCalled(); +}); diff --git a/frontend/tests/workbench/KnowledgePicker.test.tsx b/frontend/tests/workbench/KnowledgePicker.test.tsx index bf91f75785..e3af53478c 100644 --- a/frontend/tests/workbench/KnowledgePicker.test.tsx +++ b/frontend/tests/workbench/KnowledgePicker.test.tsx @@ -26,6 +26,7 @@ const fixtures = vi.hoisted(() => ({ : key; }, groups: { groups: [] }, + groupLookup: vi.fn(), predicates: [], deployment: { enableAidpKnowledge: false, isDeploymentReady: true }, })); @@ -41,7 +42,10 @@ vi.mock("@/components/providers/AuthorizationProvider", () => ({ useAuthorizationContext: () => ({ user: { tenantId: "tenant" } }), })); vi.mock("@/hooks/group/useGroupList", () => ({ - useGroupList: () => ({ data: fixtures.groups }), + useGroupList: (tenantId: string | null) => { + fixtures.groupLookup(tenantId); + return { data: fixtures.groups }; + }, })); vi.mock("@/hooks/agent/useToolList", () => ({ useToolList: () => ({ @@ -662,3 +666,28 @@ it("Workbench knowledge footer has only shared Close/Confirm", async () => { ); await waitFor(() => expect(confirm).toHaveBeenCalledOnce()); }); + +it("keeps closed knowledge group and catalog queries disabled", () => { + fixtures.groupLookup.mockClear(); + const page = render( + + ); + expect(fixtures.groupLookup).toHaveBeenLastCalledWith(null); + expect(knowledgeBaseService.getKnowledgeBasesInfo).not.toHaveBeenCalled(); + page.rerender( + + ); + expect(fixtures.groupLookup).toHaveBeenLastCalledWith("tenant"); +}); diff --git a/frontend/tests/workbench/SkillMountListApi.test.ts b/frontend/tests/workbench/SkillMountListApi.test.ts new file mode 100644 index 0000000000..3e13dff9dc --- /dev/null +++ b/frontend/tests/workbench/SkillMountListApi.test.ts @@ -0,0 +1,61 @@ +// Copyright (c) 2025-2026 Huawei Technologies Co., Ltd. All rights reserved. + +import { afterEach, beforeEach, expect, it, vi } from "vitest"; +import { fetchSkills } from "@/services/agentConfigService"; +import { fetchSkillsList } from "@/services/skillService"; +const request = vi.hoisted(() => vi.fn()); + +vi.mock("@/lib/auth", () => ({ + fetchWithAuth: vi.fn(), + getAuthHeaders: () => ({}), +})); + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal("fetch", request); + request.mockImplementation( + async () => + new Response( + JSON.stringify({ + skills: [ + { + skill_id: 7, + name: "Configured skill", + description: "Summary", + tags: [], + source: "local", + config_schemas: [ + { name: "region", type: "string", required: true }, + ], + config_values: { region: "west" }, + }, + ], + }), + { status: 200 } + ) + ); +}); + +it("preserves the default list URL and opts into mount summaries explicitly", async () => { + await fetchSkills(); + expect(String(request.mock.calls[0][0])).toMatch(/\/skills$/); + const skills = await fetchSkillsList("tenant with space", "mount"); + const url = String(request.mock.calls[1][0]); + expect(url).toContain("view=mount"); + expect(new URL(url, "http://localhost").searchParams.get("tenant_id")).toBe( + "tenant with space" + ); + expect(skills[0].config_values).toEqual({ region: "west" }); + expect(skills[0].config_schemas).toEqual([ + { name: "region", type: "string", required: true }, + ]); + expect(skills[0].tool_ids).toEqual([]); + expect(skills[0].content).toBe(""); +}); + +it("retains explicit list errors instead of accepting an empty failed summary", async () => { + request.mockResolvedValue(new Response("unavailable", { status: 503 })); + await expect(fetchSkillsList(undefined, "mount")).rejects.toThrow(); +}); + +afterEach(() => vi.unstubAllGlobals()); diff --git a/frontend/tests/workbench/SkillPicker.test.tsx b/frontend/tests/workbench/SkillPicker.test.tsx index 226c817ccf..5a1759fe7e 100644 --- a/frontend/tests/workbench/SkillPicker.test.tsx +++ b/frontend/tests/workbench/SkillPicker.test.tsx @@ -537,3 +537,28 @@ beforeEach(async () => { interpolation: { escapeValue: false }, }); }); + +it("requests mount summaries while preserving required configuration and selected IDs", async () => { + const confirm = vi.fn(); + const item = { + ...base, + config_schemas: [{ name: "region", type: "string", required: true }], + config_values: { region: "west" }, + }; + delete item.content; + vi.mocked(fetchSkillsList).mockResolvedValue([item]); + render( + + ); + await screen.findByRole("option", { name: /Default Skill/ }); + expect(fetchSkillsList).toHaveBeenCalledExactlyOnceWith(undefined, "mount"); + await userEvent.click(screen.getByRole("button", { name: "确认" })); + expect(confirm.mock.calls[0][0]).toEqual([ + { skill_id: 1, config_values: { region: "east" } }, + ]); +}); diff --git a/frontend/tests/workbench/WorkbenchBootstrapLoading.test.tsx b/frontend/tests/workbench/WorkbenchBootstrapLoading.test.tsx index a32f489152..8d0cea98d6 100644 --- a/frontend/tests/workbench/WorkbenchBootstrapLoading.test.tsx +++ b/frontend/tests/workbench/WorkbenchBootstrapLoading.test.tsx @@ -73,7 +73,9 @@ function Workbench() { resourcesResolving={session.workbenchState.resolving} >