dify/web/features/agent-v2/agent-composer/__tests__/store.spec.ts
yyh 9b432d0d29
fix: enforce tool provider type contracts (#39380)
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
2026-07-22 01:30:55 +00:00

955 lines
25 KiB
TypeScript

import type { AgentSoulConfig } from '@dify/contracts/api/console/agent/types.gen'
import { createStore } from 'jotai'
import { describe, expect, it } from 'vitest'
import { agentSoulConfigToFormState, formStateToAgentSoulConfig } from '../conversions'
import { defaultAgentSoulConfigFormState } from '../form-state'
import {
agentComposerDraftAtom,
agentComposerOriginalConfigAtom,
agentComposerOriginalDraftAtom,
agentComposerPublishedDraftAtom,
rebaseAgentComposerDraftAtom,
} from '../store'
describe('agent composer store conversions', () => {
it('should hydrate missing file and skill references from API config', () => {
const formState = agentSoulConfigToFormState({
config_files: [
{
file_id: 'missing-file-id',
file_kind: 'upload_file',
is_missing: true,
name: 'missing.pdf',
},
],
config_skills: [
{
file_id: 'missing-skill-id',
file_kind: 'tool_file',
is_missing: true,
name: 'Missing Skill',
},
],
} as unknown as AgentSoulConfig)
expect(formState.files).toEqual([
expect.objectContaining({
isMissing: true,
name: 'missing.pdf',
}),
])
expect(formState.skills).toEqual([
expect.objectContaining({
isMissing: true,
name: 'Missing Skill',
}),
])
})
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 = {
...defaultAgentSoulConfigFormState,
prompt: 'Build draft prompt',
}
const originalConfig = {
prompt: {
system_prompt: 'Build draft prompt',
},
} satisfies AgentSoulConfig
store.set(rebaseAgentComposerDraftAtom, {
draft: nextDraft,
originalConfig,
})
expect(store.get(agentComposerDraftAtom).prompt).toBe('Build draft prompt')
expect(store.get(agentComposerOriginalDraftAtom)?.prompt).toBe('Build draft prompt')
expect(store.get(agentComposerPublishedDraftAtom)?.prompt).toBe('Build draft prompt')
expect(store.get(agentComposerOriginalConfigAtom)?.prompt?.system_prompt).toBe(
'Build draft prompt',
)
})
it('should hydrate editable form state from an AgentSoulConfig and preserve it in the config snapshot', () => {
const baseConfig: AgentSoulConfig = {
app_features: {
opening_statement: 'Hello',
suggested_questions: ['What changed?'],
},
env: {
variables: [
{
key: 'REGION',
value: 'us-east-1',
},
],
secret_refs: [
{
id: 'secret-1',
key: 'OPENAI_API_KEY',
ref: 'credential-1',
value: 'credential-1',
},
],
},
knowledge: {
sets: [
{
id: 'support',
name: 'Product Docs',
description: 'Docs corpus',
datasets: [
{
id: 'dataset-1',
name: 'Product Docs',
description: 'Docs corpus',
},
],
query: {
mode: 'user_query',
value: 'release notes',
},
retrieval: {
mode: 'multiple',
top_k: 8,
score_threshold: 0.72,
reranking_enable: false,
},
},
],
},
config_skills: [
{
name: 'Tender Analyzer',
description: 'Parses RFPs.',
file_id: 'tool-file-1',
file_kind: 'tool_file',
},
],
config_files: [
{
file_id: 'drive-file-1',
file_kind: 'upload_file',
name: 'sample.pdf',
mime_type: 'application/pdf',
},
],
config_note: 'Read the proposal first.',
model: {
model: 'gpt-4.1',
model_provider: 'openai',
model_settings: {
temperature: 0.2,
max_tokens: 1024,
},
plugin_id: 'openai',
},
prompt: {
system_prompt: 'Be precise.',
},
tools: {
cli_tools: [
{
tool_name: 'run-tests',
name: 'Run Tests',
install_command: 'pnpm install',
pre_authorized: true,
env: {
variables: [
{
key: 'NODE_ENV',
value: 'test',
},
],
},
},
],
dify_tools: [
{
provider: 'DuckDuckGo',
provider_id: 'duckduckgo',
provider_type: 'builtin',
tool_name: 'ddg_search',
name: 'DuckDuckGo Search',
description: 'Search the web.',
runtime_parameters: {
query: 'latest docs',
used_in_agent_nodes: true,
},
},
],
},
}
const formState = agentSoulConfigToFormState(baseConfig)
const publishConfig = formStateToAgentSoulConfig({
baseConfig,
formState,
currentModel: formState.model,
})
expect(formState).toMatchObject({
prompt: 'Be precise.',
model: {
model: 'gpt-4.1',
model_settings: {
temperature: 0.2,
max_tokens: 1024,
},
provider: 'openai',
plugin_id: 'openai',
},
knowledgeRetrievals: [
expect.objectContaining({
id: 'support',
name: 'Product Docs',
queryMode: 'custom',
customQuery: 'release notes',
}),
],
envVariables: [
expect.objectContaining({
key: 'REGION',
scope: 'plain',
value: 'us-east-1',
}),
expect.objectContaining({
key: 'OPENAI_API_KEY',
masked: true,
scope: 'secret',
value: 'credential-1',
}),
],
skills: [
expect.objectContaining({
id: 'Tender Analyzer',
description: 'Parses RFPs.',
fileId: 'tool-file-1',
name: 'Tender Analyzer',
}),
],
files: [
expect.objectContaining({
configName: 'sample.pdf',
icon: 'pdf',
id: 'sample.pdf',
name: 'sample.pdf',
fileId: 'drive-file-1',
}),
],
})
expect(formState.tools).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'duckduckgo',
kind: 'provider',
actions: [
expect.objectContaining({
toolName: 'ddg_search',
}),
],
}),
expect.objectContaining({
id: 'run-tests',
kind: 'cli',
installCommand: 'pnpm install',
}),
]),
)
expect(formState.toolSettings['duckduckgo:ddg_search']).toEqual({
query: 'latest docs',
used_in_agent_nodes: true,
})
expect(publishConfig).not.toHaveProperty('skills_files')
expect(publishConfig).not.toHaveProperty('files')
expect(publishConfig.config_skills).toEqual([
{
name: 'Tender Analyzer',
description: 'Parses RFPs.',
file_id: 'tool-file-1',
file_kind: 'tool_file',
size: undefined,
hash: undefined,
mime_type: undefined,
},
])
expect(publishConfig.config_files).toEqual([
{
name: 'sample.pdf',
file_id: 'drive-file-1',
file_kind: 'upload_file',
size: undefined,
hash: undefined,
mime_type: 'application/pdf',
},
])
expect(publishConfig.config_note).toBe('Read the proposal first.')
expect(publishConfig.model?.model_settings).toEqual({
temperature: 0.2,
max_tokens: 1024,
})
expect(publishConfig.tools?.dify_tools).toEqual([
expect.objectContaining({
provider: 'DuckDuckGo',
provider_id: 'duckduckgo',
provider_type: 'builtin',
tool_name: 'ddg_search',
credential_type: 'unauthorized',
runtime_parameters: {
query: 'latest docs',
used_in_agent_nodes: true,
},
}),
])
expect(publishConfig.tools?.dify_tools?.[0]).not.toHaveProperty('name')
expect(publishConfig.tools?.cli_tools).toEqual([
expect.objectContaining({
name: 'Run Tests',
tool_name: 'run-tests',
install_command: 'pnpm install',
enabled: false,
pre_authorized: false,
}),
])
expect(publishConfig.knowledge).toMatchObject({
sets: [
{
id: 'support',
name: 'Product Docs',
description: 'Docs corpus',
datasets: [
{
id: 'dataset-1',
name: 'Product Docs',
description: 'Docs corpus',
},
],
query: {
mode: 'user_query',
value: 'release notes',
},
retrieval: {
mode: 'multiple',
top_k: 8,
score_threshold: 0.72,
reranking_enable: false,
},
},
],
})
expect(publishConfig.env).toMatchObject({
variables: [
expect.objectContaining({
key: 'REGION',
value: 'us-east-1',
}),
],
secret_refs: [
expect.objectContaining({
key: 'OPENAI_API_KEY',
value: 'credential-1',
}),
],
})
})
it('should hydrate legacy secret refs from ref when value is absent', () => {
const formState = agentSoulConfigToFormState({
env: {
secret_refs: [
{
id: 'secret-1',
key: 'OPENAI_API_KEY',
ref: 'credential-legacy',
},
],
},
})
expect(formState.envVariables).toEqual([
{
id: 'secret-1',
key: 'OPENAI_API_KEY',
masked: true,
scope: 'secret',
value: 'credential-legacy',
},
])
})
it('should keep unauthorized credential type when no-auth tool settings change', () => {
const publishConfig = formStateToAgentSoulConfig({
formState: {
...defaultAgentSoulConfigFormState,
tools: [
{
id: 'duckduckgo',
kind: 'provider',
name: 'duckduckgo',
iconClassName: 'i-custom-public-other-default-tool-icon text-text-tertiary',
providerType: 'builtin',
credentialVariant: 'none',
actions: [
{
id: 'duckduckgo:ddg_search',
name: 'DuckDuckGo Search',
toolName: 'ddg_search',
description: 'Search the web.',
},
],
},
],
toolSettings: {
'duckduckgo:ddg_search': {
query: 'updated query',
},
},
},
})
expect(publishConfig.tools?.dify_tools).toEqual([
expect.objectContaining({
provider_id: 'duckduckgo',
tool_name: 'ddg_search',
credential_type: 'unauthorized',
runtime_parameters: {
query: 'updated query',
},
}),
])
})
it('should preserve oauth2 credential references when saving tool config', () => {
const baseConfig = {
tools: {
dify_tools: [
{
provider: 'google',
provider_id: 'google',
provider_type: 'builtin',
tool_name: 'search',
credential_type: 'oauth2',
credential_ref: {
id: 'credential-oauth',
provider: 'google',
type: 'provider',
},
},
],
},
} satisfies AgentSoulConfig
const formState = agentSoulConfigToFormState(baseConfig)
const publishConfig = formStateToAgentSoulConfig({ baseConfig, formState })
expect(formState.tools).toEqual([
expect.objectContaining({
credentialId: 'credential-oauth',
credentialType: 'oauth2',
credentialVariant: 'authorized',
}),
])
expect(publishConfig.tools?.dify_tools).toEqual([
expect.objectContaining({
credential_type: 'oauth2',
credential_ref: {
id: 'credential-oauth',
provider: 'google',
type: 'provider',
},
}),
])
})
it('should hydrate oauth tool authorization state from credential refs', () => {
const formState = agentSoulConfigToFormState({
tools: {
dify_tools: [
{
provider: 'google',
provider_id: 'google',
provider_type: 'builtin',
tool_name: 'search',
credential_type: 'oauth2',
},
{
provider: 'slack',
provider_id: 'slack',
provider_type: 'builtin',
tool_name: 'post_message',
credential_type: 'oauth2',
credential_ref: {
id: 'slack-oauth',
provider: 'slack',
type: 'provider',
},
},
],
},
})
expect(formState.tools).toEqual([
expect.objectContaining({
id: 'google',
credentialType: 'oauth2',
credentialVariant: 'unauthorized',
}),
expect.objectContaining({
id: 'slack',
credentialId: 'slack-oauth',
credentialType: 'oauth2',
credentialVariant: 'authorized',
}),
])
})
it('should not save credentialed tool config without a credential reference', () => {
const baseConfig = {
tools: {
dify_tools: [
{
provider: 'google',
provider_id: 'google',
provider_type: 'builtin',
tool_name: 'search',
credential_type: 'oauth2',
},
],
},
} satisfies AgentSoulConfig
const formState = agentSoulConfigToFormState(baseConfig)
const publishConfig = formStateToAgentSoulConfig({ baseConfig, formState })
expect(formState.tools).toEqual([
expect.objectContaining({
credentialId: undefined,
credentialType: 'oauth2',
credentialVariant: 'unauthorized',
}),
])
expect(publishConfig.tools?.dify_tools).toEqual([
expect.objectContaining({
credential_type: 'unauthorized',
credential_ref: undefined,
}),
])
})
it('should derive model plugin_id from the selected provider when publishing', () => {
const publishConfig = formStateToAgentSoulConfig({
baseConfig: {
model: {
model: 'gpt-4.1',
model_provider: 'langgenius/openai/openai',
plugin_id: 'langgenius/openai',
},
},
formState: defaultAgentSoulConfigFormState,
currentModel: {
provider: 'langgenius/anthropic/anthropic',
model: 'claude-sonnet-4',
},
})
expect(publishConfig.model).toMatchObject({
model: 'claude-sonnet-4',
model_provider: 'langgenius/anthropic/anthropic',
plugin_id: 'langgenius/anthropic',
})
})
it('should not hydrate a knowledge retrieval row when the config has no sets', () => {
const formState = agentSoulConfigToFormState({
knowledge: {
sets: [],
},
})
expect(formState.knowledgeRetrievals).toEqual([])
})
it('should keep explicitly cleared selected datasets instead of falling back to dataset refs', () => {
const publishConfig = formStateToAgentSoulConfig({
formState: {
...defaultAgentSoulConfigFormState,
knowledgeRetrievals: [
{
id: 'retrieval-1',
name: 'Docs Search',
selectedDatasets: [],
datasetRefs: [
{
id: 'dataset-stale',
name: 'Stale Docs',
description: 'Should stay cleared',
},
],
},
],
},
})
expect(publishConfig.knowledge).toMatchObject({
sets: [
{
id: 'retrieval-1',
datasets: [],
},
],
})
})
it('should round-trip single retrieval model config', () => {
const baseConfig: AgentSoulConfig = {
knowledge: {
sets: [
{
id: 'retrieval-1',
name: 'Docs Search',
datasets: [{ id: 'dataset-1', name: 'Docs' }],
query: { mode: 'generated_query' },
retrieval: {
mode: 'single',
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4.1',
mode: 'chat',
completion_params: { temperature: 0.1 },
},
},
},
],
},
}
const formState = agentSoulConfigToFormState(baseConfig)
const publishConfig = formStateToAgentSoulConfig({ baseConfig, formState })
expect(formState.knowledgeRetrievals).toEqual([
expect.objectContaining({
id: 'retrieval-1',
retrievalMode: 'single',
singleRetrievalConfig: {
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4.1',
mode: 'chat',
completion_params: { temperature: 0.1 },
},
},
}),
])
expect(publishConfig.knowledge).toMatchObject({
sets: [
{
id: 'retrieval-1',
retrieval: {
mode: 'single',
model: {
provider: 'langgenius/openai/openai',
name: 'gpt-4.1',
mode: 'chat',
completion_params: { temperature: 0.1 },
},
},
},
],
})
})
it('should round-trip automatic metadata filtering model config', () => {
const baseConfig: AgentSoulConfig = {
knowledge: {
sets: [
{
id: 'retrieval-1',
name: 'Docs Search',
datasets: [{ id: 'dataset-1', name: 'Docs' }],
query: { mode: 'generated_query' },
retrieval: { mode: 'multiple', top_k: 4 },
metadata_filtering: {
mode: 'automatic',
model_config: {
provider: 'langgenius/openai/openai',
name: 'gpt-4.1-mini',
mode: 'chat',
completion_params: { temperature: 0.2 },
},
},
},
],
},
}
const formState = agentSoulConfigToFormState(baseConfig)
const publishConfig = formStateToAgentSoulConfig({ baseConfig, formState })
expect(formState.knowledgeRetrievals).toEqual([
expect.objectContaining({
id: 'retrieval-1',
metadataFilterMode: 'automatic',
metadataModelConfig: {
provider: 'langgenius/openai/openai',
name: 'gpt-4.1-mini',
mode: 'chat',
completion_params: { temperature: 0.2 },
},
}),
])
expect(publishConfig.knowledge).toMatchObject({
sets: [
{
id: 'retrieval-1',
metadata_filtering: {
mode: 'automatic',
model_config: {
provider: 'langgenius/openai/openai',
name: 'gpt-4.1-mini',
mode: 'chat',
completion_params: { temperature: 0.2 },
},
},
},
],
})
})
it('should round-trip manual metadata filtering conditions', () => {
const baseConfig: AgentSoulConfig = {
knowledge: {
sets: [
{
id: 'retrieval-1',
name: 'Docs Search',
datasets: [{ id: 'dataset-1', name: 'Docs' }],
query: { mode: 'generated_query' },
retrieval: { mode: 'multiple', top_k: 4 },
metadata_filtering: {
mode: 'manual',
conditions: {
logical_operator: 'and',
conditions: [
{
name: 'language',
comparison_operator: 'is',
value: 'en',
},
],
},
},
},
],
},
}
const formState = agentSoulConfigToFormState(baseConfig)
const publishConfig = formStateToAgentSoulConfig({ baseConfig, formState })
expect(formState.knowledgeRetrievals).toEqual([
expect.objectContaining({
id: 'retrieval-1',
metadataFilterMode: 'manual',
metadataFilteringConditions: {
logical_operator: 'and',
conditions: [
{
name: 'language',
comparison_operator: 'is',
value: 'en',
},
],
},
}),
])
expect(publishConfig.knowledge).toMatchObject({
sets: [
{
id: 'retrieval-1',
metadata_filtering: {
mode: 'manual',
conditions: {
logical_operator: 'and',
conditions: [
{
name: 'language',
comparison_operator: 'is',
value: 'en',
},
],
},
},
},
],
})
})
it('should omit incomplete environment variables from the config snapshot', () => {
const publishConfig = formStateToAgentSoulConfig({
formState: {
...defaultAgentSoulConfigFormState,
envVariables: [
{
id: 'empty-env',
key: '',
value: '',
scope: 'plain',
},
{
id: 'empty-value-env',
key: 'EMPTY_VALUE',
value: '',
scope: 'plain',
},
{
id: 'empty-key-env',
key: '',
value: 'secret',
scope: 'plain',
},
{
id: 'invalid-key-env',
key: '1BAD',
value: 'secret',
scope: 'plain',
},
{
id: 'valid-env',
key: ' REGION ',
value: 'us-east-1',
scope: 'plain',
},
{
id: 'empty-secret',
key: '',
value: '',
scope: 'secret',
},
{
id: 'empty-secret-value',
key: 'EMPTY_SECRET_VALUE',
value: '',
scope: 'secret',
},
{
id: 'invalid-secret-key',
key: 'BAD-SECRET',
value: 'secret',
scope: 'secret',
},
{
id: 'valid-secret',
key: ' OPENAI_API_KEY ',
value: '********',
scope: 'secret',
masked: true,
},
],
tools: [
{
id: 'run-tests',
name: 'Run Tests',
kind: 'cli',
envVariables: [
{
id: 'empty-cli-env',
key: '',
value: '',
scope: 'plain',
},
{
id: 'invalid-cli-env',
key: 'BAD-CLI',
value: 'test',
scope: 'plain',
},
{
id: 'valid-cli-env',
key: 'NODE_ENV',
value: 'test',
scope: 'plain',
},
],
},
],
},
})
expect(publishConfig.env).toEqual({
variables: [
{
id: 'valid-env',
key: 'REGION',
name: 'REGION',
value: 'us-east-1',
variable: 'REGION',
},
],
secret_refs: [
{
id: 'valid-secret',
key: 'OPENAI_API_KEY',
name: 'OPENAI_API_KEY',
value: '********',
variable: 'OPENAI_API_KEY',
},
],
})
expect(publishConfig.tools?.cli_tools?.[0]?.env).toEqual({
variables: [
{
id: 'valid-cli-env',
key: 'NODE_ENV',
name: 'NODE_ENV',
value: 'test',
variable: 'NODE_ENV',
},
],
secret_refs: [],
})
expect(publishConfig.tools?.cli_tools?.[0]).toMatchObject({
enabled: false,
pre_authorized: false,
})
})
})