fix: fix delete skill ref

This commit is contained in:
fatelei 2026-07-22 14:11:55 +08:00
parent eb5d1da0e8
commit 577012b66d
No known key found for this signature in database
GPG Key ID: 2F91DA05646F4EED
16 changed files with 448 additions and 81 deletions

View File

@ -1247,6 +1247,13 @@ class SkillManagementService:
"skill is referenced and requires name confirmation",
status_code=409,
)
self._remove_skill_reference_consumers(
session,
tenant_id=tenant_id,
skill=skill,
user_id=skill.updated_by,
updated_at=naive_utc_now(),
)
session.query(AgentSkillBinding).filter(
AgentSkillBinding.tenant_id == tenant_id,
AgentSkillBinding.skill_id == skill.id,
@ -1998,6 +2005,121 @@ class SkillManagementService:
binding.updated_by = user_id
binding.updated_at = updated_at
def _remove_skill_reference_consumers(
self,
session,
*,
tenant_id: str,
skill: Skill,
user_id: str,
updated_at,
) -> None:
agents = list(
session.scalars(
select(Agent)
.join(AgentSkillBinding, AgentSkillBinding.agent_id == Agent.id)
.where(
AgentSkillBinding.tenant_id == tenant_id,
AgentSkillBinding.skill_id == skill.id,
Agent.tenant_id == tenant_id,
)
)
)
if not agents:
return
workflow_bindings_by_agent_id = self._workflow_inline_bindings_by_agent_id(
session,
tenant_id=tenant_id,
agent_ids=[agent.id for agent in agents],
)
for agent in agents:
self._remove_agent_config_skill_ref(
session,
agent=agent,
skill_name=skill.name,
user_id=user_id,
updated_at=updated_at,
workflow_bindings=workflow_bindings_by_agent_id.get(agent.id, []),
)
agent.updated_by = user_id
agent.updated_at = updated_at
def _remove_agent_config_skill_ref(
self,
session,
*,
agent: Agent,
skill_name: str,
user_id: str,
updated_at,
workflow_bindings: list[WorkflowAgentNodeBinding],
) -> None:
new_snapshot_id: str | None = None
previous_active_snapshot_id = agent.active_config_snapshot_id
if previous_active_snapshot_id:
active_snapshot = session.scalar(
select(AgentConfigSnapshot).where(
AgentConfigSnapshot.tenant_id == agent.tenant_id,
AgentConfigSnapshot.agent_id == agent.id,
AgentConfigSnapshot.id == previous_active_snapshot_id,
)
)
if active_snapshot is not None:
agent_soul = AgentSoulConfig.model_validate(active_snapshot.config_snapshot_dict)
agent_soul.config_skills = self._remove_config_skill_ref(agent_soul.config_skills, skill_name)
new_snapshot = AgentConfigSnapshot(
tenant_id=agent.tenant_id,
agent_id=agent.id,
version=self._next_agent_config_version(session, tenant_id=agent.tenant_id, agent_id=agent.id),
config_snapshot=agent_soul,
version_note=f"Removed workspace skill {skill_name}",
created_by=user_id,
)
session.add(new_snapshot)
session.flush()
session.add(
AgentConfigRevision(
tenant_id=agent.tenant_id,
agent_id=agent.id,
previous_snapshot_id=active_snapshot.id,
current_snapshot_id=new_snapshot.id,
revision=self._next_agent_config_revision(
session,
tenant_id=agent.tenant_id,
agent_id=agent.id,
),
operation=AgentConfigRevisionOperation.SAVE_CURRENT_VERSION,
version_note=f"Removed workspace skill {skill_name}",
created_by=user_id,
)
)
agent.active_config_snapshot_id = new_snapshot.id
new_snapshot_id = new_snapshot.id
drafts = list(
session.scalars(
select(AgentConfigDraft).where(
AgentConfigDraft.tenant_id == agent.tenant_id,
AgentConfigDraft.agent_id == agent.id,
)
)
)
for draft in drafts:
draft_soul = AgentSoulConfig.model_validate(draft.config_snapshot_dict)
draft_soul.config_skills = self._remove_config_skill_ref(draft_soul.config_skills, skill_name)
draft.config_snapshot = draft_soul
if new_snapshot_id and draft.base_snapshot_id == previous_active_snapshot_id:
draft.base_snapshot_id = new_snapshot_id
draft.updated_by = user_id
draft.updated_at = updated_at
for binding in workflow_bindings:
if new_snapshot_id:
binding.current_snapshot_id = new_snapshot_id
binding.updated_by = user_id
binding.updated_at = updated_at
@staticmethod
def _upsert_config_skill_ref(
current: list[AgentConfigSkillRefConfig],
@ -2010,6 +2132,13 @@ class SkillManagementService:
by_name[skill_ref.name] = skill_ref
return [by_name[name] for name in order if name in by_name]
@staticmethod
def _remove_config_skill_ref(
current: list[AgentConfigSkillRefConfig],
skill_name: str,
) -> list[AgentConfigSkillRefConfig]:
return [item for item in current if item.name != skill_name]
@staticmethod
def _next_agent_config_version(session, *, tenant_id: str, agent_id: str) -> int:
return (

View File

@ -10,7 +10,7 @@ from unittest.mock import patch
from uuid import uuid4
import pytest
from sqlalchemy import delete, select
from sqlalchemy import delete, func, select
from core.db.session_factory import session_factory
from models.account import Account
@ -1255,6 +1255,28 @@ def test_duplicate_skill_copies_latest_published_content_without_history() -> No
assert service.list_versions(tenant_id=TENANT, skill_id=duplicated["id"]) == {"data": []}
def test_duplicate_skill_does_not_copy_agent_references() -> None:
service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
created = service.create_skill(
tenant_id=TENANT,
user_id=USER,
payload=SkillCreatePayload(name="finance-sop"),
)
service.replace_agent_bindings(tenant_id=TENANT, user_id=USER, agent_id=AGENT, skill_ids=[created["id"]])
duplicated = service.duplicate_skill(tenant_id=TENANT, user_id=USER, skill_id=created["id"])
assert duplicated["reference_count"] == 0
assert service.list_skill_references(tenant_id=TENANT, skill_id=duplicated["id"]) == {"data": []}
assert service.list_agent_bindings(tenant_id=TENANT, agent_id=AGENT)["skill_ids"] == [created["id"]]
listed = service.list_skills(tenant_id=TENANT, keyword=None, tags=[], page=1, limit=10)
ref_counts_by_name = {skill["name"]: skill["reference_count"] for skill in listed["data"]}
assert ref_counts_by_name == {
"finance-sop": 1,
"finance-sop-copy": 0,
}
def test_duplicate_unpublished_skill_copies_current_draft() -> None:
service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload(name="finance-sop"))
@ -1286,6 +1308,94 @@ def test_delete_skill_requires_confirmation_when_referenced() -> None:
assert service.list_skills(tenant_id=TENANT)["data"] == []
def test_delete_skill_removes_synced_agent_config_skill_refs() -> None:
service = SkillManagementService(tool_file_manager=_FakeToolFileManager())
created = service.create_skill(tenant_id=TENANT, user_id=USER, payload=SkillCreatePayload(name="finance-sop"))
with session_factory.create_session() as session:
agent_snapshot = AgentConfigSnapshot(
tenant_id=TENANT,
agent_id=AGENT,
version=1,
config_snapshot=AgentSoulConfig(
config_skills=[
AgentConfigSkillRefConfig(
name="finance-sop",
description="Finance SOP",
file_id="workspace-skill-file",
size=1,
hash="workspace-skill-hash",
),
AgentConfigSkillRefConfig(
name="inline-helper",
description="Inline helper",
file_id="inline-skill-file",
size=1,
hash="inline-skill-hash",
),
]
),
created_by=USER,
)
session.add(agent_snapshot)
session.flush()
agent = session.get(Agent, AGENT)
assert agent is not None
agent.active_config_snapshot_id = agent_snapshot.id
session.add(
AgentConfigDraft(
tenant_id=TENANT,
agent_id=AGENT,
draft_type=AgentConfigDraftType.DRAFT,
account_id=None,
draft_owner_key="",
base_snapshot_id=agent_snapshot.id,
config_snapshot=AgentSoulConfig(
config_skills=[
AgentConfigSkillRefConfig(
name="finance-sop",
description="Finance SOP",
file_id="workspace-skill-file",
size=1,
hash="workspace-skill-hash",
)
]
),
created_by=USER,
updated_by=USER,
)
)
session.commit()
service.replace_agent_bindings(tenant_id=TENANT, user_id=USER, agent_id=AGENT, skill_ids=[created["id"]])
deleted = service.delete_skill(tenant_id=TENANT, skill_id=created["id"], confirmation_name="finance-sop")
with session_factory.create_session() as session:
agent = session.get(Agent, AGENT)
assert agent is not None
active_snapshot = session.get(AgentConfigSnapshot, agent.active_config_snapshot_id)
draft = session.scalar(
select(AgentConfigDraft).where(
AgentConfigDraft.agent_id == AGENT,
AgentConfigDraft.draft_type == AgentConfigDraftType.DRAFT,
)
)
binding_count = session.scalar(select(func.count()).select_from(AgentSkillBinding))
assert deleted == {"id": created["id"], "deleted": True}
assert active_snapshot is not None
assert draft is not None
assert binding_count == 0
assert active_snapshot.version == 2
active_skill_names = [
item.name for item in AgentSoulConfig.model_validate(active_snapshot.config_snapshot_dict).config_skills
]
draft_skill_names = [item.name for item in AgentSoulConfig.model_validate(draft.config_snapshot_dict).config_skills]
assert active_skill_names == ["inline-helper"]
assert draft_skill_names == []
assert draft.base_snapshot_id == active_snapshot.id
def test_import_skill_package_creates_draft_and_rejects_name_conflicts() -> None:
package = io.BytesIO()
with zipfile.ZipFile(package, "w") as archive:

View File

@ -8,15 +8,15 @@ import type {
} from './add-actions-context'
import { useCallback, useMemo, useState } from 'react'
import { AgentOrchestrateAddActionsContext } from './add-actions-context'
import { useAgentOrchestrateReadOnly } from './read-only-context'
import { useAgentOrchestrateViewingVersion } from './read-only-context'
export function AgentOrchestrateAddActionsProvider({ children }: { children: ReactNode }) {
const readOnly = useAgentOrchestrateReadOnly()
const isViewingVersion = useAgentOrchestrateViewingVersion()
const [actions, setActions] = useState<AgentOrchestrateAddActions>({})
const registerAction = useCallback(
(key: AgentOrchestrateAddActionKey, action: AgentOrchestrateAddAction) => {
if (readOnly) return () => undefined
if (isViewingVersion) return () => undefined
setActions((currentActions) => {
if (currentActions[key] === action) return currentActions
@ -37,15 +37,15 @@ export function AgentOrchestrateAddActionsProvider({ children }: { children: Rea
})
}
},
[readOnly],
[isViewingVersion],
)
const value = useMemo(
() => ({
actions: readOnly ? {} : actions,
actions: isViewingVersion ? {} : actions,
registerAction,
}),
[actions, readOnly, registerAction],
[actions, isViewingVersion, registerAction],
)
return (

View File

@ -4,7 +4,7 @@ import type { ButtonProps } from '@langgenius/dify-ui/button'
import { Button } from '@langgenius/dify-ui/button'
import { cn } from '@langgenius/dify-ui/cn'
import { useTranslation } from 'react-i18next'
import { useAgentOrchestrateReadOnly } from '../read-only-context'
import { useAgentOrchestrateViewingVersion } from '../read-only-context'
type ConfigureSectionAddButtonProps = Omit<
ButtonProps,
@ -19,9 +19,9 @@ export function ConfigureSectionAddButton({
...props
}: ConfigureSectionAddButtonProps) {
const { t } = useTranslation('common')
const readOnly = useAgentOrchestrateReadOnly()
const isViewingVersion = useAgentOrchestrateViewingVersion()
if (readOnly) return null
if (isViewingVersion) return null
return (
<Button

View File

@ -12,7 +12,10 @@ import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-compo
import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider'
import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store'
import { AgentConfigApiContextProvider } from '../../config-context'
import { AgentOrchestrateReadOnlyContext } from '../../read-only-context'
import {
AgentOrchestrateReadOnlyContext,
AgentOrchestrateViewingVersionContext,
} from '../../read-only-context'
import { AgentFiles } from '../index'
type ConfigFileQueryOptionsInput = {
@ -161,11 +164,13 @@ function renderAgentFiles({
initialOriginalConfig,
apiContext = { agentId: 'agent-1', draftType: 'draft' } satisfies AgentConfigApiContext,
readOnly = false,
viewingVersion = false,
}: {
initialDraft?: AgentSoulConfigFormState
initialOriginalConfig?: AgentSoulConfig
apiContext?: AgentConfigApiContext
readOnly?: boolean
viewingVersion?: boolean
} = {}) {
const queryClient = new QueryClient({
defaultOptions: {
@ -181,10 +186,12 @@ function renderAgentFiles({
initialDraft={initialDraft}
initialOriginalConfig={initialOriginalConfig}
>
<AgentOrchestrateReadOnlyContext value={readOnly}>
<AgentFiles />
<ConfigSnapshotProbe />
</AgentOrchestrateReadOnlyContext>
<AgentOrchestrateViewingVersionContext value={viewingVersion}>
<AgentOrchestrateReadOnlyContext value={readOnly}>
<AgentFiles />
<ConfigSnapshotProbe />
</AgentOrchestrateReadOnlyContext>
</AgentOrchestrateViewingVersionContext>
</AgentComposerProvider>
</AgentConfigApiContextProvider>
</QueryClientProvider>,
@ -632,8 +639,8 @@ describe('AgentFiles', () => {
expect(snapshot.config_note).toBe('')
})
it('should keep flat config files visible without drive-prefix filtering and disable add in read-only mode', () => {
renderAgentFiles({ readOnly: true })
it('should keep flat config files visible without drive-prefix filtering and disable add when viewing a version', () => {
renderAgentFiles({ readOnly: true, viewingVersion: true })
expect(screen.getByText('diagram.png')).toBeInTheDocument()
expect(screen.getByText('brief.md')).toBeInTheDocument()
@ -641,4 +648,12 @@ describe('AgentFiles', () => {
screen.queryByRole('button', { name: /agentV2\.agentDetail\.configure\.files\.add/i }),
).not.toBeInTheDocument()
})
it('should keep add action available for build drafts', () => {
renderAgentFiles({ readOnly: true })
expect(
screen.getByRole('button', { name: /agentV2\.agentDetail\.configure\.files\.add/i }),
).toBeInTheDocument()
})
})

View File

@ -23,7 +23,10 @@ import { AgentKnowledgeRetrieval } from './knowledge'
import { AgentModelField } from './model-config/field'
import { AgentPromptEditor } from './prompt-editor'
import { AgentConfigurePublishBar } from './publish-bar'
import { AgentOrchestrateReadOnlyContext } from './read-only-context'
import {
AgentOrchestrateReadOnlyContext,
AgentOrchestrateViewingVersionContext,
} from './read-only-context'
import { AgentSkills } from './skills'
import { AgentTools } from './tools'
@ -144,41 +147,43 @@ export function AgentOrchestratePanel({
/>
)}
<AgentOrchestrateReadOnlyContext value={readOnly}>
<div aria-readonly={readOnly} className="flex min-h-0 flex-1 flex-col">
<ScrollArea
className="min-h-0 flex-1 overflow-hidden"
label={showHeader ? undefined : orchestrateLabel}
slotClassNames={{
viewport: 'overscroll-contain',
content: cn('min-h-full px-4 py-3', hasBottomAction && 'pb-20'),
scrollbar: hasBottomAction ? 'z-20' : undefined,
}}
>
<AgentConfigApiContextProvider value={configApiContext}>
<AgentOrchestrateAddActionsProvider>
<AgentBuildDraftChangedKeysProvider
changedKeys={
isBuildDraftActive ? buildDraftChangedKeys : EMPTY_BUILD_DRAFT_CHANGED_KEYS
}
>
<AgentModelField
currentModel={currentModel}
textGenerationModelList={textGenerationModelList}
onSelect={onSelectModel}
/>
<AgentPromptEditor />
<AgentSkills />
<AgentFiles />
<AgentTools />
<AgentKnowledgeRetrieval />
<AgentAdvancedSettings />
</AgentBuildDraftChangedKeysProvider>
</AgentOrchestrateAddActionsProvider>
</AgentConfigApiContextProvider>
</ScrollArea>
</div>
</AgentOrchestrateReadOnlyContext>
<AgentOrchestrateViewingVersionContext value={!!selectedVersionSnapshot}>
<AgentOrchestrateReadOnlyContext value={readOnly}>
<div aria-readonly={readOnly} className="flex min-h-0 flex-1 flex-col">
<ScrollArea
className="min-h-0 flex-1 overflow-hidden"
label={showHeader ? undefined : orchestrateLabel}
slotClassNames={{
viewport: 'overscroll-contain',
content: cn('min-h-full px-4 py-3', hasBottomAction && 'pb-20'),
scrollbar: hasBottomAction ? 'z-20' : undefined,
}}
>
<AgentConfigApiContextProvider value={configApiContext}>
<AgentOrchestrateAddActionsProvider>
<AgentBuildDraftChangedKeysProvider
changedKeys={
isBuildDraftActive ? buildDraftChangedKeys : EMPTY_BUILD_DRAFT_CHANGED_KEYS
}
>
<AgentModelField
currentModel={currentModel}
textGenerationModelList={textGenerationModelList}
onSelect={onSelectModel}
/>
<AgentPromptEditor />
<AgentSkills />
<AgentFiles />
<AgentTools />
<AgentKnowledgeRetrieval />
<AgentAdvancedSettings />
</AgentBuildDraftChangedKeysProvider>
</AgentOrchestrateAddActionsProvider>
</AgentConfigApiContextProvider>
</ScrollArea>
</div>
</AgentOrchestrateReadOnlyContext>
</AgentOrchestrateViewingVersionContext>
{orchestrateBottomAction ? (
<AgentOrchestrateBottomActions shrinkOnOpen={!bottomAction}>

View File

@ -11,7 +11,10 @@ import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provid
import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store'
import { RerankingModeEnum } from '@/models/datasets'
import { renderWithAccountProfile as render } from '@/test/console/account-profile'
import { AgentOrchestrateReadOnlyContext } from '../../read-only-context'
import {
AgentOrchestrateReadOnlyContext,
AgentOrchestrateViewingVersionContext,
} from '../../read-only-context'
import { AgentKnowledgeRetrieval } from '../index'
vi.mock('@/context/workspace-state', async () => {
@ -108,10 +111,12 @@ function ConfigSnapshotPreview() {
function renderKnowledgeRetrieval({
initialDraft = agentKnowledgeDraft,
readOnly = false,
viewingVersion = false,
showConfigSnapshot = false,
}: {
initialDraft?: AgentSoulConfigFormState
readOnly?: boolean
viewingVersion?: boolean
showConfigSnapshot?: boolean
} = {}) {
const queryClient = new QueryClient()
@ -119,9 +124,11 @@ function renderKnowledgeRetrieval({
return render(
<QueryClientProvider client={queryClient}>
<AgentComposerProvider initialDraft={initialDraft}>
<AgentOrchestrateReadOnlyContext value={readOnly}>
<AgentKnowledgeRetrieval />
</AgentOrchestrateReadOnlyContext>
<AgentOrchestrateViewingVersionContext value={viewingVersion}>
<AgentOrchestrateReadOnlyContext value={readOnly}>
<AgentKnowledgeRetrieval />
</AgentOrchestrateReadOnlyContext>
</AgentOrchestrateViewingVersionContext>
{showConfigSnapshot && <ConfigSnapshotPreview />}
</AgentComposerProvider>
</QueryClientProvider>,
@ -159,8 +166,8 @@ describe('AgentKnowledgeRetrieval', () => {
).not.toBeInTheDocument()
})
it('should hide add, edit, and remove actions when readonly', () => {
renderKnowledgeRetrieval({ readOnly: true })
it('should hide add, edit, and remove actions when viewing a version', () => {
renderKnowledgeRetrieval({ readOnly: true, viewingVersion: true })
expect(
screen.getByText('agentV2.agentDetail.configure.knowledgeRetrieval.retrievalOne'),
@ -181,6 +188,16 @@ describe('AgentKnowledgeRetrieval', () => {
}),
).not.toBeInTheDocument()
})
it('should keep add action available for build drafts', () => {
renderKnowledgeRetrieval({ readOnly: true })
expect(
screen.getByRole('button', {
name: 'agentV2.agentDetail.configure.knowledgeRetrieval.add',
}),
).toBeInTheDocument()
})
})
describe('User Interactions', () => {

View File

@ -1,7 +1,12 @@
import { createContext, use } from 'react'
export const AgentOrchestrateReadOnlyContext = createContext(false)
export const AgentOrchestrateViewingVersionContext = createContext(false)
export function useAgentOrchestrateReadOnly() {
return use(AgentOrchestrateReadOnlyContext)
}
export function useAgentOrchestrateViewingVersion() {
return use(AgentOrchestrateViewingVersionContext)
}

View File

@ -12,7 +12,10 @@ import { defaultAgentSoulConfigFormState } from '@/features/agent-v2/agent-compo
import { AgentComposerProvider } from '@/features/agent-v2/agent-composer/provider'
import { agentComposerDraftAtom } from '@/features/agent-v2/agent-composer/store'
import { AgentConfigApiContextProvider } from '../../config-context'
import { AgentOrchestrateReadOnlyContext } from '../../read-only-context'
import {
AgentOrchestrateReadOnlyContext,
AgentOrchestrateViewingVersionContext,
} from '../../read-only-context'
import { AgentSkills } from '../index'
type ConfigSkillInspectQueryOptionsInput = {
@ -243,10 +246,12 @@ function renderAgentSkills({
},
apiContext = { agentId: 'agent-1', draftType: 'draft' } satisfies AgentConfigApiContext,
readOnly = false,
viewingVersion = false,
}: {
initialDraft?: AgentSoulConfigFormState
apiContext?: AgentConfigApiContext
readOnly?: boolean
viewingVersion?: boolean
} = {}) {
const queryClient = new QueryClient({
defaultOptions: {
@ -259,10 +264,12 @@ function renderAgentSkills({
<QueryClientProvider client={queryClient}>
<AgentConfigApiContextProvider value={apiContext}>
<AgentComposerProvider initialDraft={initialDraft}>
<AgentOrchestrateReadOnlyContext value={readOnly}>
<AgentSkills />
<ConfigSnapshotProbe />
</AgentOrchestrateReadOnlyContext>
<AgentOrchestrateViewingVersionContext value={viewingVersion}>
<AgentOrchestrateReadOnlyContext value={readOnly}>
<AgentSkills />
<ConfigSnapshotProbe />
</AgentOrchestrateReadOnlyContext>
</AgentOrchestrateViewingVersionContext>
</AgentComposerProvider>
</AgentConfigApiContextProvider>
</QueryClientProvider>,
@ -553,7 +560,7 @@ describe('AgentSkills', () => {
})
})
const snapshot = JSON.parse(screen.getByTestId('config-snapshot-probe').textContent ?? '{}')
const snapshot = JSON.parse(screen.getByLabelText('config snapshot').textContent ?? '{}')
expect(snapshot.config_skills).toEqual([])
})
@ -1183,12 +1190,52 @@ describe('AgentSkills', () => {
)
})
it('should disable add and remove actions when the section is read only', () => {
const { container } = renderAgentSkills({ readOnly: true })
it('should disable add and remove actions when viewing a version', () => {
const { container } = renderAgentSkills({
apiContext: {
agentId: 'agent-1',
draftType: 'draft',
versionId: 'version-1',
},
readOnly: true,
viewingVersion: true,
})
expect(
screen.queryByRole('button', { name: /agentV2\.agentDetail\.configure\.skills\.add/i }),
).not.toBeInTheDocument()
expect(container.querySelector('[data-agent-skill-remove-button]')).toBeNull()
})
it('should keep the add menu available for build draft skills', async () => {
const user = userEvent.setup()
renderAgentSkills({
apiContext: {
agentId: 'agent-1',
draftType: 'debug_build',
},
initialDraft: {
...defaultAgentSoulConfigFormState,
skills: [],
},
readOnly: true,
})
await user.click(
await screen.findByRole('button', {
name: /agentV2\.agentDetail\.configure\.skills\.add/i,
}),
)
expect(
await screen.findByRole('button', {
name: /agentV2\.agentDetail\.configure\.skills\.addMenu\.workspace\.label/i,
}),
).toBeInTheDocument()
expect(
screen.getByRole('button', {
name: /agentV2\.agentDetail\.configure\.skills\.addMenu\.upload\.label/i,
}),
).toBeInTheDocument()
})
})

View File

@ -42,7 +42,10 @@ import { ConfigureSectionEmpty } from '../common/empty'
import { ConfigureSection } from '../common/section'
import { AgentConfigureTipContent } from '../common/tip-content'
import { useAgentConfigApiContext } from '../config-context'
import { useAgentOrchestrateReadOnly } from '../read-only-context'
import {
useAgentOrchestrateReadOnly,
useAgentOrchestrateViewingVersion,
} from '../read-only-context'
import { AgentSkillItem } from './item'
import { AgentSkillUploadDialog } from './upload-dialog'
@ -376,7 +379,7 @@ export function AgentSkills() {
const skillsTip = t(($) => $['agentDetail.configure.skills.tip'])
const skillsListId = 'agent-configure-skills-list'
const queryClient = useQueryClient()
const readOnly = useAgentOrchestrateReadOnly()
const isViewingVersion = useAgentOrchestrateViewingVersion()
const [addMenuOpen, setAddMenuOpen] = useState(false)
const [addMenuView, setAddMenuView] = useState<'menu' | 'workspace-selector'>('menu')
const [isUploadOpen, setIsUploadOpen] = useState(false)
@ -560,7 +563,7 @@ export function AgentSkills() {
rootClassName="border-b border-divider-subtle pt-4"
panelContentClassName="flex flex-col gap-1 pb-4"
actions={
!readOnly && (
!isViewingVersion && (
<Popover open={addMenuOpen} onOpenChange={handleAddMenuOpenChange}>
<PopoverTrigger
render={

View File

@ -16,7 +16,10 @@ import {
agentComposerPublishedDraftAtom,
isAgentComposerDirtyAtom,
} from '@/features/agent-v2/agent-composer/store'
import { AgentOrchestrateReadOnlyContext } from '../../read-only-context'
import {
AgentOrchestrateReadOnlyContext,
AgentOrchestrateViewingVersionContext,
} from '../../read-only-context'
import { AgentTools } from '../index'
const toolProviderState = vi.hoisted(() => ({
@ -342,7 +345,13 @@ function renderAgentToolsWithStore(initialDraft: AgentSoulConfigFormState = agen
}
}
function renderReadonlyAgentTools(initialDraft: AgentSoulConfigFormState = agentToolsDraft) {
function renderReadonlyAgentTools({
initialDraft = agentToolsDraft,
viewingVersion = false,
}: {
initialDraft?: AgentSoulConfigFormState
viewingVersion?: boolean
} = {}) {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
@ -354,9 +363,11 @@ function renderReadonlyAgentTools(initialDraft: AgentSoulConfigFormState = agent
return render(
<QueryClientProvider client={queryClient}>
<AgentComposerProvider initialDraft={initialDraft}>
<AgentOrchestrateReadOnlyContext value>
<AgentTools />
</AgentOrchestrateReadOnlyContext>
<AgentOrchestrateViewingVersionContext value={viewingVersion}>
<AgentOrchestrateReadOnlyContext value>
<AgentTools />
</AgentOrchestrateReadOnlyContext>
</AgentOrchestrateViewingVersionContext>
</AgentComposerProvider>
</QueryClientProvider>,
)
@ -443,9 +454,9 @@ describe('AgentTools', () => {
).toBeInTheDocument()
})
it('should hide add, edit, and remove actions when readonly', async () => {
it('should hide add, edit, and remove actions when viewing a version', async () => {
const user = userEvent.setup()
renderReadonlyAgentTools()
renderReadonlyAgentTools({ viewingVersion: true })
expect(
screen.queryByRole('button', {
@ -486,6 +497,16 @@ describe('AgentTools', () => {
).not.toBeInTheDocument()
})
it('should keep add action available for build drafts', () => {
renderReadonlyAgentTools()
expect(
screen.getByRole('button', {
name: 'agentV2.agentDetail.configure.tools.add',
}),
).toBeInTheDocument()
})
it('should hide CLI tool rows while CLI tools are disabled', () => {
renderAgentTools()

View File

@ -36,7 +36,7 @@ import { ConfigureSectionAddButton } from '../common/add-button'
import { ConfigureSectionEmpty } from '../common/empty'
import { ConfigureSection } from '../common/section'
import { AgentConfigureTipContent } from '../common/tip-content'
import { useAgentOrchestrateReadOnly } from '../read-only-context'
import { useAgentOrchestrateViewingVersion } from '../read-only-context'
import { CliToolDialog } from './cli-tool/dialog'
import { AgentCliToolItem } from './cli-tool/item'
import {
@ -400,7 +400,7 @@ function AddToolMenu({
export function AgentTools() {
const { t } = useTranslation('agentV2')
const readOnly = useAgentOrchestrateReadOnly()
const isViewingVersion = useAgentOrchestrateViewingVersion()
const setProviderToolCredential = useSetAtom(setProviderToolCredentialAtom)
const providerById = useAgentToolProviderMap()
const tools = useAtomValue(agentComposerToolsAtom)
@ -533,7 +533,7 @@ export function AgentTools() {
rootClassName="border-b border-divider-subtle pt-4"
panelContentClassName="flex flex-col gap-1 pb-4"
actions={
!readOnly ? (
!isViewingVersion ? (
<AddToolMenu
onAddCliTool={openCliToolDialog}
onAddTools={addTools}

View File

@ -343,6 +343,12 @@ describe('SkillsPage', () => {
await user.click(await screen.findByText('common.operation.delete'))
const dialog = await screen.findByRole('alertdialog')
expect(
within(dialog).getByText(
'agentV2.skillManagement.deleteDialog.referencedDescription:{"count":2}',
),
).toBeInTheDocument()
await user.click(within(dialog).getByRole('button', { name: 'common.operation.delete' }))
await waitFor(() => {

View File

@ -210,6 +210,13 @@ function DeleteSkillDialog({
const deleteMutation = useMutation(
consoleQuery.workspaces.current.skills.bySkillId.delete.mutationOptions(),
)
const referenceCount = skill.reference_count ?? 0
const description =
referenceCount > 0
? t(($) => $['skillManagement.deleteDialog.referencedDescription'], {
count: referenceCount,
})
: t(($) => $['skillManagement.deleteDialog.description'])
const handleDelete = () => {
if (deleteMutation.isPending) return
@ -246,7 +253,7 @@ function DeleteSkillDialog({
{t(($) => $['skillManagement.deleteDialog.title'], { name: skill.display_name })}
</AlertDialogTitle>
<AlertDialogDescription className="mt-2 system-md-regular wrap-break-word whitespace-pre-wrap text-text-tertiary">
{t(($) => $['skillManagement.deleteDialog.description'])}
{description}
</AlertDialogDescription>
<AlertDialogActions className="p-0 pt-6">
<AlertDialogCancelButton disabled={deleteMutation.isPending}>

View File

@ -444,6 +444,7 @@
"skillManagement.createSuccess": "Skill created.",
"skillManagement.dateTimeFormat": "MMM D, YYYY HH:mm",
"skillManagement.deleteDialog.description": "This skill will be removed from the workspace. Agents that reference it may lose access to this capability.",
"skillManagement.deleteDialog.referencedDescription": "This Skill is referenced by {{count}} Agent, Workflow, or Chatflow item. Deleting it will remove the Skill from the workspace and clear those bindings.",
"skillManagement.deleteDialog.title": "Delete {{name}}?",
"skillManagement.deleteFailed": "Failed to delete skill.",
"skillManagement.deleteSuccess": "Skill deleted.",

View File

@ -444,6 +444,7 @@
"skillManagement.createSuccess": "Skill 已创建。",
"skillManagement.dateTimeFormat": "YYYY-MM-DD HH:mm",
"skillManagement.deleteDialog.description": "该 Skill 将从 workspace 中移除。引用它的 Agent 可能失去对应能力。",
"skillManagement.deleteDialog.referencedDescription": "该 Skill 当前被 {{count}} 个 Agent、Workflow 或 Chatflow 引用。删除后会从 workspace 中移除该 Skill并清理这些绑定关系。",
"skillManagement.deleteDialog.title": "删除 {{name}}",
"skillManagement.deleteFailed": "Skill 删除失败。",
"skillManagement.deleteSuccess": "Skill 已删除。",