mirror of
https://github.com/langgenius/dify.git
synced 2026-07-21 02:28:30 +08:00
fix: validate inline agent files and skills in the workflow checklist (#39137)
This commit is contained in:
parent
cd98193234
commit
d876f6dba5
@ -1,8 +1,11 @@
|
||||
import type { CommonNodeType, Node } from '../../types'
|
||||
import type { ChecklistItem } from '../use-checklist'
|
||||
import { zWorkflowAgentComposerResponse } from '@dify/contracts/api/console/apps/zod.gen'
|
||||
import { QueryClient } from '@tanstack/react-query'
|
||||
import { screen, waitFor } from '@testing-library/react'
|
||||
import { createElement, Fragment } from 'react'
|
||||
import { CollectionType } from '@/app/components/tools/types'
|
||||
import { consoleQuery } from '@/service/client'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { createEdge, createNode, resetFixtureCounters } from '../../__tests__/fixtures'
|
||||
import { resetReactFlowMockState, rfState } from '../../__tests__/reactflow-mock-state'
|
||||
@ -146,6 +149,10 @@ function setupNodesMap() {
|
||||
checkValid: () => ({ errorMessage: '' }),
|
||||
metaData: { isStart: false, isRequired: false },
|
||||
}
|
||||
mockNodesMap[BlockEnum.AgentV2] = {
|
||||
checkValid: () => ({ errorMessage: '' }),
|
||||
metaData: { isStart: false, isRequired: false },
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@ -175,6 +182,82 @@ function buildConnectedGraph() {
|
||||
return { nodes, edges }
|
||||
}
|
||||
|
||||
function buildInlineAgentGraph({
|
||||
hasMissingFile,
|
||||
hasMissingSkill,
|
||||
}: {
|
||||
hasMissingFile: boolean
|
||||
hasMissingSkill: boolean
|
||||
}) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, staleTime: Infinity },
|
||||
},
|
||||
})
|
||||
const appId = 'app-id'
|
||||
const nodeId = 'inline-agent-node'
|
||||
queryClient.setQueryData(
|
||||
consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryKey({
|
||||
input: {
|
||||
params: {
|
||||
app_id: appId,
|
||||
node_id: nodeId,
|
||||
},
|
||||
},
|
||||
}),
|
||||
zWorkflowAgentComposerResponse.parse({
|
||||
agent_soul: {
|
||||
config_files: [
|
||||
{ file_kind: 'upload_file', name: 'available.pdf' },
|
||||
...(hasMissingFile
|
||||
? [{ file_kind: 'upload_file', is_missing: true, name: 'missing.pdf' }]
|
||||
: []),
|
||||
],
|
||||
config_skills: [
|
||||
{ name: 'Available Skill' },
|
||||
...(hasMissingSkill ? [{ is_missing: true, name: 'Missing Skill' }] : []),
|
||||
],
|
||||
},
|
||||
node_job: {},
|
||||
save_options: [],
|
||||
soul_lock: { locked: false },
|
||||
variant: 'workflow',
|
||||
}),
|
||||
)
|
||||
|
||||
const startNode = createNode({ id: 'start', data: { type: BlockEnum.Start, title: 'Start' } })
|
||||
const agentNode = createNode({
|
||||
id: nodeId,
|
||||
data: {
|
||||
type: BlockEnum.AgentV2,
|
||||
title: 'Inline Agent',
|
||||
agent_node_kind: 'dify_agent',
|
||||
version: '2',
|
||||
agent_binding: {
|
||||
binding_type: 'inline_agent',
|
||||
agent_id: 'inline-agent-id',
|
||||
current_snapshot_id: 'snapshot-id',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
edges: [createEdge({ source: 'start', target: nodeId })],
|
||||
nodeId,
|
||||
nodes: [startNode, agentNode],
|
||||
options: {
|
||||
queryClient,
|
||||
hooksStoreProps: {
|
||||
configsMap: {
|
||||
flowId: appId,
|
||||
flowType: FlowType.appFlow,
|
||||
fileSettings: {} as never,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// useChecklist
|
||||
// ---------------------------------------------------------------------------
|
||||
@ -222,6 +305,43 @@ describe('useChecklist', () => {
|
||||
expect(warning!.errorMessages).toContain('Model not configured')
|
||||
})
|
||||
|
||||
it.each([
|
||||
{
|
||||
errorMessage: 'agentV2.agentDetail.configure.files.missing',
|
||||
hasMissingFile: true,
|
||||
hasMissingSkill: false,
|
||||
referenceType: 'file',
|
||||
},
|
||||
{
|
||||
errorMessage: 'agentV2.agentDetail.configure.skills.missing',
|
||||
hasMissingFile: false,
|
||||
hasMissingSkill: true,
|
||||
referenceType: 'skill',
|
||||
},
|
||||
])('should report a missing $referenceType reference from inline agents', async (scenario) => {
|
||||
const { edges, nodeId, nodes, options } = buildInlineAgentGraph(scenario)
|
||||
const { result } = renderWorkflowHook(() => useChecklist(nodes, edges), options)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current).toEqual([
|
||||
expect.objectContaining({
|
||||
id: nodeId,
|
||||
errorMessages: [scenario.errorMessage],
|
||||
}),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('should not report available file and skill references from inline agents', () => {
|
||||
const { edges, nodes, options } = buildInlineAgentGraph({
|
||||
hasMissingFile: false,
|
||||
hasMissingSkill: false,
|
||||
})
|
||||
const { result } = renderWorkflowHook(() => useChecklist(nodes, edges), options)
|
||||
|
||||
expect(result.current).toEqual([])
|
||||
})
|
||||
|
||||
it('should pass flow type to node validators', () => {
|
||||
const checkValid = vi.fn(() => ({ errorMessage: '' }))
|
||||
mockNodesMap[BlockEnum.LLM] = {
|
||||
|
||||
@ -15,7 +15,6 @@ import type {
|
||||
import type { ModelItem } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { Emoji } from '@/app/components/tools/types'
|
||||
import type { DataSet } from '@/models/datasets'
|
||||
import type { FlowType } from '@/types/common'
|
||||
import type { I18nKeysWithPrefix } from '@/types/i18n'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useQueries, useQueryClient } from '@tanstack/react-query'
|
||||
@ -42,12 +41,13 @@ import {
|
||||
} from '@/service/use-tools'
|
||||
import { useAllTriggerPlugins } from '@/service/use-triggers'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
import { FlowType } from '@/types/common'
|
||||
import { CUSTOM_NODE } from '../constants'
|
||||
import { useDatasetsDetailStore } from '../datasets-detail-store/store'
|
||||
import { useGetToolIcon, useNodesMetaData } from '../hooks'
|
||||
import { useHooksStore } from '../hooks-store/store'
|
||||
import { getNodeUsedVars, isSpecialVar } from '../nodes/_base/components/variable/utils'
|
||||
import { isAgentV2NodeData } from '../nodes/agent-v2/types'
|
||||
import { hasValidInlineAgentBinding, isAgentV2NodeData } from '../nodes/agent-v2/types'
|
||||
import { IndexMethodEnum } from '../nodes/knowledge-base/types'
|
||||
import {
|
||||
getLLMModelIssue,
|
||||
@ -152,8 +152,71 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType?
|
||||
appMode === AppModeEnum.WORKFLOW || appMode === AppModeEnum.ADVANCED_CHAT
|
||||
const modelProviders = useProviderContextSelector((s) => s.modelProviders)
|
||||
const workflowStore = useWorkflowStore()
|
||||
const configsMap = useHooksStore((s) => s.configsMap)
|
||||
|
||||
const map = useNodesAvailableVarList(nodes)
|
||||
const inlineAgentNodes = useMemo(
|
||||
() =>
|
||||
nodes.filter(
|
||||
(node) =>
|
||||
node.type === CUSTOM_NODE &&
|
||||
isAgentV2NodeData(node.data) &&
|
||||
hasValidInlineAgentBinding(node.data),
|
||||
),
|
||||
[nodes],
|
||||
)
|
||||
const inlineAgentMissingReferences = useQueries({
|
||||
queries:
|
||||
!configsMap?.flowId ||
|
||||
(configsMap.flowType !== FlowType.appFlow && configsMap.flowType !== FlowType.snippet)
|
||||
? []
|
||||
: inlineAgentNodes.map((node) =>
|
||||
configsMap.flowType === FlowType.snippet
|
||||
? consoleQuery.snippets.bySnippetId.workflows.draft.nodes.byNodeId.agentComposer.get.queryOptions(
|
||||
{
|
||||
input: {
|
||||
params: {
|
||||
snippet_id: configsMap.flowId,
|
||||
node_id: node.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
: consoleQuery.apps.byAppId.workflows.draft.nodes.byNodeId.agentComposer.get.queryOptions(
|
||||
{
|
||||
input: {
|
||||
params: {
|
||||
app_id: configsMap.flowId,
|
||||
node_id: node.id,
|
||||
},
|
||||
},
|
||||
},
|
||||
),
|
||||
),
|
||||
combine: (results) => {
|
||||
const missingReferences: Record<
|
||||
string,
|
||||
{ hasMissingFiles: boolean; hasMissingSkills: boolean }
|
||||
> = {}
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const nodeId = inlineAgentNodes[index]?.id
|
||||
const agentSoul = result.data?.agent_soul
|
||||
if (!nodeId || !agentSoul) return
|
||||
|
||||
const hasMissingFiles = agentSoul.config_files?.some((file) => file.is_missing === true)
|
||||
const hasMissingSkills = agentSoul.config_skills?.some((skill) => skill.is_missing === true)
|
||||
if (!hasMissingFiles && !hasMissingSkills) return
|
||||
|
||||
missingReferences[nodeId] = {
|
||||
hasMissingFiles: !!hasMissingFiles,
|
||||
hasMissingSkills: !!hasMissingSkills,
|
||||
}
|
||||
})
|
||||
|
||||
return missingReferences
|
||||
},
|
||||
})
|
||||
const { data: embeddingModelList } = useModelList(ModelTypeEnum.textEmbedding)
|
||||
const { data: rerankModelList } = useModelList(ModelTypeEnum.rerank)
|
||||
const knowledgeBaseEmbeddingProviders = useMemo(() => {
|
||||
@ -315,6 +378,16 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType?
|
||||
if (validationError) errorMessages.push(validationError)
|
||||
}
|
||||
|
||||
const missingReferences = inlineAgentMissingReferences[node!.id]
|
||||
if (missingReferences?.hasMissingFiles)
|
||||
errorMessages.push(
|
||||
t(($) => $['agentDetail.configure.files.missing'], { ns: 'agentV2' }),
|
||||
)
|
||||
if (missingReferences?.hasMissingSkills)
|
||||
errorMessages.push(
|
||||
t(($) => $['agentDetail.configure.skills.missing'], { ns: 'agentV2' }),
|
||||
)
|
||||
|
||||
const availableVars = map[node!.id]!.availableVars
|
||||
let hasInvalidVar = false
|
||||
for (const variable of usedVars) {
|
||||
@ -423,6 +496,7 @@ export const useChecklist = (nodes: Node[], edges: Edge[], options?: { flowType?
|
||||
t,
|
||||
map,
|
||||
modelProviders,
|
||||
inlineAgentMissingReferences,
|
||||
options?.flowType,
|
||||
])
|
||||
|
||||
|
||||
@ -46,6 +46,45 @@ describe('agent composer store conversions', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('should preserve missing file and skill references without file ids in autosave config', () => {
|
||||
const baseConfig = {
|
||||
config_files: [
|
||||
{
|
||||
file_id: '',
|
||||
file_kind: 'upload_file',
|
||||
is_missing: true,
|
||||
name: 'missing.pdf',
|
||||
},
|
||||
],
|
||||
config_skills: [
|
||||
{
|
||||
file_id: '',
|
||||
file_kind: 'tool_file',
|
||||
is_missing: true,
|
||||
name: 'Missing Skill',
|
||||
},
|
||||
],
|
||||
} satisfies AgentSoulConfig
|
||||
const formState = agentSoulConfigToFormState(baseConfig)
|
||||
|
||||
const autosaveConfig = formStateToAgentSoulConfig({ baseConfig, formState })
|
||||
|
||||
expect(autosaveConfig.config_files).toEqual([
|
||||
expect.objectContaining({
|
||||
file_id: '',
|
||||
is_missing: true,
|
||||
name: 'missing.pdf',
|
||||
}),
|
||||
])
|
||||
expect(autosaveConfig.config_skills).toEqual([
|
||||
expect.objectContaining({
|
||||
file_id: '',
|
||||
is_missing: true,
|
||||
name: 'Missing Skill',
|
||||
}),
|
||||
])
|
||||
})
|
||||
|
||||
it('rebases draft baselines through the composer state action', () => {
|
||||
const store = createStore()
|
||||
const nextDraft = {
|
||||
|
||||
@ -452,7 +452,7 @@ const toConfigSkillConfigs = (
|
||||
return skills.flatMap((skill) => {
|
||||
const existing = existingByName.get(skill.name)
|
||||
const fileId = skill.fileId ?? existing?.file_id
|
||||
if (!fileId) return []
|
||||
if (!fileId && !skill.isMissing) return []
|
||||
|
||||
return [
|
||||
{
|
||||
@ -463,6 +463,7 @@ const toConfigSkillConfigs = (
|
||||
size: skill.size ?? existing?.size,
|
||||
hash: skill.hash ?? existing?.hash,
|
||||
mime_type: skill.mimeType ?? existing?.mime_type,
|
||||
...(skill.isMissing ? { is_missing: true } : {}),
|
||||
},
|
||||
]
|
||||
})
|
||||
@ -480,7 +481,7 @@ const toConfigFileConfigs = (
|
||||
const configName = file.configName ?? file.name
|
||||
const existing = existingByName.get(configName)
|
||||
const fileId = file.fileId ?? existing?.file_id
|
||||
if (!fileId) return []
|
||||
if (!fileId && !file.isMissing) return []
|
||||
|
||||
return [
|
||||
{
|
||||
@ -490,6 +491,7 @@ const toConfigFileConfigs = (
|
||||
size: file.size ?? existing?.size,
|
||||
hash: file.hash ?? existing?.hash,
|
||||
mime_type: file.mimeType ?? existing?.mime_type,
|
||||
...(file.isMissing ? { is_missing: true } : {}),
|
||||
},
|
||||
]
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user