fix: validate inline agent files and skills in the workflow checklist (#39137)

This commit is contained in:
Joel 2026-07-16 16:39:20 +08:00 committed by GitHub
parent cd98193234
commit d876f6dba5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 239 additions and 4 deletions

View File

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

View File

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

View File

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

View File

@ -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 } : {}),
},
]
})