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, }) }) })