diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 4136c625a7d..8cd95d26bf5 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -7975,9 +7975,11 @@ "agents.builder.advanced.webSearch.externalAccess.label": "Live page access", "agents.builder.advanced.webSearch.externalAccess.hint": "Allow OpenAI to fetch live page content", "agents.builder.advanced.webSearch.contextSize.label": "Search context", - "agents.builder.advanced.webSearch.method.label": "Provider", + "agents.builder.advanced.webSearch.contextSize.hint": "How much space web search results use in the model's context window", + "agents.builder.advanced.webSearch.contextSize.low": "Low", + "agents.builder.advanced.webSearch.contextSize.medium": "Medium", + "agents.builder.advanced.webSearch.contextSize.high": "High", "agents.builder.advanced.webSearch.method.off": "Off", - "agents.builder.advanced.webSearch.method.native": "Native", "agents.builder.advanced.webSearch.fallbackProvider.brave": "Brave Search", "agents.builder.advanced.webSearch.fallbackProvider.searxng": "SearXNG", "agents.builder.advanced.webSearch.credential.label": "Credential", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentAdvancedPanel.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentAdvancedPanel.test.ts index e2118848ba1..3da36192909 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentAdvancedPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentAdvancedPanel.test.ts @@ -5,7 +5,7 @@ import { nextTick, ref } from 'vue'; import type * as VueUse from '@vueuse/core'; import AgentAdvancedPanel from '../components/AgentAdvancedPanel.vue'; -import AgentCredentialSelect from '../components/AgentCredentialSelect.vue'; +import AgentWebSearchSection from '../components/AgentWebSearchSection.vue'; import type { ProviderCatalog } from '../composables/useAgentApi'; import type { AgentJsonConfig } from '../types'; @@ -192,50 +192,6 @@ function findStubComponent(wrapper: ReturnType, testId: string) { }; } -type WebSearchConfig = { - enabled: boolean; - provider?: string; - credential?: string; -}; - -function getWebSearchConfig(changes: Partial): WebSearchConfig | undefined { - return ( - changes.config as - | (NonNullable & { webSearch?: WebSearchConfig }) - | undefined - )?.webSearch; -} - -/** Mounts the panel with the Brave fallback picker visible for `projectId`. */ -function mountWithFallbackPicker({ - projectId = 'project-1', - disabled = false, -}: { projectId?: string; disabled?: boolean } = {}) { - return mount(AgentAdvancedPanel, { - props: { - config: makeConfig({ - model: 'deepseek/deepseek-chat', - config: { webSearch: { enabled: true, provider: 'brave' } }, - } as Partial), - projectId, - disabled, - }, - global: { stubs: globalStubs }, - }); -} - -function findCreateCredentialButton(wrapper: ReturnType) { - return wrapper.find('[data-test-id="node-credentials-select-item-new"]'); -} - -function getCreateCredentialTooltip(wrapper: ReturnType) { - const tooltip = findCreateCredentialButton(wrapper).element.closest('[data-tooltip-disabled]'); - return { - disabled: tooltip?.getAttribute('data-tooltip-disabled'), - content: tooltip?.getAttribute('data-tooltip-content'), - }; -} - describe('AgentAdvancedPanel', () => { beforeEach(() => { ensureLoadedMock.mockReset(); @@ -246,346 +202,27 @@ describe('AgentAdvancedPanel', () => { projectsStoreState.myProjects = []; }); - it('renders both advanced sections without a collapsible heading', () => { + it('renders the advanced section without a collapsible heading', () => { const wrapper = mount(AgentAdvancedPanel, { props: { config: makeConfig() }, global: { stubs: globalStubs }, }); - expect(wrapper.text()).toContain('agents.builder.advanced.webSearch.label'); expect(wrapper.text()).toContain('agents.builder.advanced.title'); expect(wrapper.find('[data-testid="agent-advanced-trigger"]').exists()).toBe(false); expect(wrapper.find('[data-testid="agent-advanced-chevron"]').exists()).toBe(false); - expect(wrapper.find('[data-testid="agent-advanced-content"]').isVisible()).toBe(true); + expect(wrapper.find('[data-testid="agent-behavior-panel"]').isVisible()).toBe(true); + expect(wrapper.find('[data-testid="agent-reasoning-toggle"]').isVisible()).toBe(true); }); - it('treats sparse native web search config as disabled', async () => { + it('does not render web search controls', function doesNotRenderWebSearchControls() { const wrapper = mount(AgentAdvancedPanel, { props: { config: makeConfig() }, global: { stubs: globalStubs }, }); - const method = findStubComponent(wrapper, 'agent-web-search-method'); - expect(method.exists()).toBe(true); - expect(method.props('modelValue')).toBe('off'); - - emitSelectValue(wrapper, 'agent-web-search-method', 'native'); - await nextTick(); - const events = wrapper.emitted('update:config') ?? []; - const last = events[events.length - 1][0] as Partial; - expect(getWebSearchConfig(last)).toEqual({ enabled: true, provider: 'native' }); - expect(last.providerTools).toEqual({ 'anthropic.web_search': { maxUses: 5 } }); - }); - - it('emits provider-specific web search options', async () => { - const config = makeConfig({ - model: 'openai/gpt-5', - config: { webSearch: { enabled: true } }, - providerTools: { 'openai.web_search': {} }, - } as Partial); - const wrapper = mount(AgentAdvancedPanel, { - props: { config }, - global: { stubs: globalStubs }, - }); - - await wrapper.find('[data-testid="agent-web-search-external-access"]').trigger('click'); - - const events = wrapper.emitted('update:config') ?? []; - const last = events[events.length - 1][0] as Partial; - expect(last.providerTools).toEqual({ - 'openai.web_search': { - externalWebAccess: false, - searchContextSize: 'medium', - }, - }); - }); - - it('strips native web search provider tools when native web search is disabled', async () => { - const config = makeConfig({ - config: { webSearch: { enabled: true } }, - providerTools: { - 'anthropic.web_search': { maxUses: 5 }, - 'openai.image_generation': {}, - }, - } as Partial); - const wrapper = mount(AgentAdvancedPanel, { - props: { config }, - global: { stubs: globalStubs }, - }); - - emitSelectValue(wrapper, 'agent-web-search-method', 'off'); - await nextTick(); - - const events = wrapper.emitted('update:config') ?? []; - const last = events[events.length - 1][0] as Partial; - expect(getWebSearchConfig(last)).toEqual({ enabled: false }); - expect(last.providerTools).toEqual({ 'openai.image_generation': {} }); - }); - - it('enables fallback web search for providers without native web search', async () => { - const config = makeConfig({ model: 'deepseek/deepseek-chat' }); - const wrapper = mount(AgentAdvancedPanel, { - props: { config }, - global: { stubs: globalStubs }, - }); - - emitSelectValue(wrapper, 'agent-web-search-method', 'brave'); - await nextTick(); - - const events = wrapper.emitted('update:config') ?? []; - const last = events[events.length - 1][0] as Partial; - expect(getWebSearchConfig(last)).toEqual({ enabled: true, provider: 'brave' }); - }); - - it('keeps fallback controls visible on native-capable models', async () => { - const config = makeConfig({ - config: { webSearch: { enabled: true, provider: 'brave', credential: 'brave-1' } }, - providerTools: { 'anthropic.web_search': { maxUses: 5 } }, - } as Partial); - const wrapper = mount(AgentAdvancedPanel, { - props: { config }, - global: { stubs: globalStubs }, - }); - - expect(wrapper.find('[data-testid="agent-web-search-method"]').exists()).toBe(true); - expect(wrapper.find('[data-test-id="agent-web-search-fallback-credential"]').exists()).toBe( - true, - ); - expect(wrapper.find('[data-testid="agent-web-search-max-uses"]').exists()).toBe(false); - }); - - it.each([ - ['brave', 'braveSearchApi'], - ['searxng', 'searXngApi'], - ] as const)( - "creating a credential from the fallback picker opens the credential modal for the provider's type in the panel's project and selects the created credential", - async (provider, credentialType) => { - const wrapper = mount(AgentAdvancedPanel, { - props: { - config: makeConfig({ - model: 'deepseek/deepseek-chat', - config: { webSearch: { enabled: true, provider } }, - } as Partial), - projectId: 'project-1', - }, - global: { stubs: globalStubs }, - }); - - wrapper.findComponent(AgentCredentialSelect).vm.$emit('create'); - - expect(openNewCredentialMock).toHaveBeenCalledWith( - credentialType, - false, - false, - 'project-1', - undefined, - undefined, - undefined, - expect.objectContaining({ hideAskAssistant: true }), - ); - - const onCredentialCreated = openNewCredentialMock.mock.calls.at(-1)?.[7]?.onCredentialCreated; - expect(onCredentialCreated).toBeTypeOf('function'); - onCredentialCreated?.({ id: 'new-cred' }); - - const last = wrapper.emitted('update:config')?.at(-1)?.[0] as Partial; - expect(getWebSearchConfig(last)).toEqual({ - enabled: true, - provider, - credential: 'new-cred', - }); - }, - ); - - it('offers an enabled "Create new credential" action without the permission tooltip when the user can create credentials in the project', () => { - const wrapper = mountWithFallbackPicker(); - - const createButton = findCreateCredentialButton(wrapper); - expect(createButton.text()).toBe('Create new credential'); - expect(createButton.attributes()).not.toHaveProperty('disabled'); - expect(getCreateCredentialTooltip(wrapper).disabled).toBe('true'); - }); - - it('disables "Create new credential" with the permission tooltip and does not open the credential modal when the user cannot create credentials in the project', () => { - projectsStoreState.currentProject = { id: 'project-1', scopes: ['credential:read'] }; - - const wrapper = mountWithFallbackPicker(); - - expect( - wrapper.findComponent(AgentCredentialSelect).props('credentialPermissions'), - ).toMatchObject({ create: false }); - expect(findCreateCredentialButton(wrapper).attributes()).toHaveProperty('disabled'); - expect(getCreateCredentialTooltip(wrapper)).toEqual({ - disabled: 'false', - content: 'Your current role does not allow you to create credentials', - }); - - // Even if the dropdown emits `create` anyway, the panel must not open the modal. - wrapper.findComponent(AgentCredentialSelect).vm.$emit('create'); - expect(openNewCredentialMock).not.toHaveBeenCalled(); - expect(wrapper.emitted('update:config')).toBeUndefined(); - }); - - it('does not open the credential modal from the fallback picker while the panel is disabled', () => { - const wrapper = mountWithFallbackPicker({ disabled: true }); - - wrapper.findComponent(AgentCredentialSelect).vm.$emit('create'); - - expect(openNewCredentialMock).not.toHaveBeenCalled(); - expect(wrapper.emitted('update:config')).toBeUndefined(); - }); - - describe('resolving the project whose scopes decide whether a credential can be created', () => { - const canCreate = CREDENTIAL_CREATE_SCOPES; - const cannotCreate = ['credential:read']; - - it.each<{ - name: string; - projectId: string; - currentProject: MockProject; - personalProject: MockProject; - myProjects: MockProject[]; - expectedCreate: boolean; - }>([ - { - name: 'uses the current project when it matches the panel project', - projectId: 'project-1', - currentProject: { id: 'project-1', scopes: canCreate }, - personalProject: { id: 'project-2', scopes: cannotCreate }, - myProjects: [{ id: 'project-3', scopes: cannotCreate }], - expectedCreate: true, - }, - { - name: 'falls back to the personal project when the current project does not match', - projectId: 'project-2', - currentProject: { id: 'project-1', scopes: cannotCreate }, - personalProject: { id: 'project-2', scopes: canCreate }, - myProjects: [{ id: 'project-3', scopes: cannotCreate }], - expectedCreate: true, - }, - { - name: 'falls back to the matching entry in myProjects when neither the current nor the personal project match', - projectId: 'project-3', - currentProject: { id: 'project-1', scopes: cannotCreate }, - personalProject: { id: 'project-2', scopes: cannotCreate }, - myProjects: [{ id: 'project-3', scopes: canCreate }], - expectedCreate: true, - }, - { - name: 'denies creating credentials when no known project matches the panel project', - projectId: 'project-unknown', - currentProject: { id: 'project-1', scopes: canCreate }, - personalProject: { id: 'project-2', scopes: canCreate }, - myProjects: [{ id: 'project-3', scopes: canCreate }], - expectedCreate: false, - }, - ])('$name', ({ projectId, currentProject, personalProject, myProjects, expectedCreate }) => { - projectsStoreState.currentProject = currentProject; - projectsStoreState.personalProject = personalProject; - projectsStoreState.myProjects = myProjects; - - const wrapper = mountWithFallbackPicker({ projectId }); - - expect( - wrapper.findComponent(AgentCredentialSelect).props('credentialPermissions'), - ).toMatchObject({ create: expectedCreate }); - - wrapper.findComponent(AgentCredentialSelect).vm.$emit('create'); - if (expectedCreate) { - expect(openNewCredentialMock).toHaveBeenCalledWith( - 'braveSearchApi', - false, - false, - projectId, - undefined, - undefined, - undefined, - expect.objectContaining({ hideAskAssistant: true }), - ); - } else { - expect(openNewCredentialMock).not.toHaveBeenCalled(); - } - }); - }); - - it('switches fallback web search to native and emits native provider tools', async () => { - const config = makeConfig({ - config: { webSearch: { enabled: true, provider: 'brave', credential: 'brave-1' } }, - } as Partial); - const wrapper = mount(AgentAdvancedPanel, { - props: { config }, - global: { stubs: globalStubs }, - }); - - emitSelectValue(wrapper, 'agent-web-search-method', 'native'); - await nextTick(); - - const events = wrapper.emitted('update:config') ?? []; - const last = events[events.length - 1][0] as Partial; - expect(getWebSearchConfig(last)).toEqual({ enabled: true, provider: 'native' }); - expect(last.providerTools).toEqual({ 'anthropic.web_search': { maxUses: 5 } }); - }); - - it('preserves fallback web search credential when switching away and back to the same fallback provider', async () => { - const config = makeConfig({ - config: { webSearch: { enabled: true, provider: 'brave', credential: 'brave-1' } }, - } as Partial); - const wrapper = mount(AgentAdvancedPanel, { - props: { config }, - global: { stubs: globalStubs }, - }); - - emitSelectValue(wrapper, 'agent-web-search-method', 'native'); - await nextTick(); - emitSelectValue(wrapper, 'agent-web-search-method', 'brave'); - await nextTick(); - - const events = wrapper.emitted('update:config') ?? []; - const last = events[events.length - 1][0] as Partial; - expect(getWebSearchConfig(last)).toEqual({ - enabled: true, - provider: 'brave', - credential: 'brave-1', - }); - }); - - it('clears fallback web search credential when switching fallback providers', async () => { - const config = makeConfig({ - config: { webSearch: { enabled: true, provider: 'brave', credential: 'brave-1' } }, - } as Partial); - const wrapper = mount(AgentAdvancedPanel, { - props: { config }, - global: { stubs: globalStubs }, - }); - - emitSelectValue(wrapper, 'agent-web-search-method', 'searxng'); - await nextTick(); - - const events = wrapper.emitted('update:config') ?? []; - const last = events[events.length - 1][0] as Partial; - expect(getWebSearchConfig(last)).toEqual({ enabled: true, provider: 'searxng' }); - }); - - it('switches native web search to fallback and strips native provider tools', async () => { - const config = makeConfig({ - config: { webSearch: { enabled: true, provider: 'native' } }, - providerTools: { - 'anthropic.web_search': { maxUses: 5 }, - 'openai.image_generation': {}, - }, - } as Partial); - const wrapper = mount(AgentAdvancedPanel, { - props: { config }, - global: { stubs: globalStubs }, - }); - - emitSelectValue(wrapper, 'agent-web-search-method', 'brave'); - await nextTick(); - - const events = wrapper.emitted('update:config') ?? []; - const last = events[events.length - 1][0] as Partial; - expect(getWebSearchConfig(last)).toEqual({ enabled: true, provider: 'brave' }); - expect(last.providerTools).toEqual({ 'openai.image_generation': {} }); + expect(wrapper.findComponent(AgentWebSearchSection).exists()).toBe(false); + expect(wrapper.find('[data-testid="agent-web-search-method"]').exists()).toBe(false); }); it('loads the model catalog for the current project', () => { @@ -880,8 +517,6 @@ describe('AgentAdvancedPanel', () => { props: { config, disabled: true }, global: { stubs: globalStubs }, }); - const webSearchMethod = findStubComponent(wrapper, 'agent-web-search-method'); - expect(webSearchMethod.props('disabled')).toBe(true); expect( wrapper.find('[data-testid="agent-reasoning-toggle"]').attributes('disabled'), ).toBeDefined(); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentCapabilitiesSection.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentCapabilitiesSection.test.ts index ba472abb23d..06e555c9098 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentCapabilitiesSection.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentCapabilitiesSection.test.ts @@ -69,6 +69,14 @@ vi.mock('@n8n/i18n', () => ({ }), })); +vi.mock('../components/AgentWebSearchSection.vue', () => ({ + default: { + name: 'AgentWebSearchSection', + emits: ['update:config'], + template: '
', + }, +})); + function mountSection( tools: AgentJsonToolRef[], customTools: Record = {}, @@ -158,6 +166,16 @@ describe('AgentCapabilitiesSection', () => { integrationsCatalogRef.value = []; }); + it('renders web search and forwards its config updates', () => { + const wrapper = mountSection([]); + const webSearchSection = wrapper.getComponent({ name: 'AgentWebSearchSection' }); + const update = { config: { webSearch: { enabled: false } } }; + + webSearchSection.vm.$emit('update:config', update); + + expect(wrapper.emitted('update:config')?.[0]).toEqual([update]); + }); + it('formats node and custom tool chip labels for display', () => { getNodeType.mockReturnValue(null); @@ -997,42 +1015,17 @@ describe('AgentCapabilitiesSection', () => { }); it('renders only the allowlisted sections and skips sub-agents', async () => { - const wrapper = mount(AgentCapabilitiesSection, { - props: { - config: null, - tools: [], - customTools: {}, - skills: [], - projectId: 'project-id', - agentId: 'agent-id', - isPublished: false, - sections: ['tools', 'skills'], - }, - global: { - stubs: { - NodeIcon: { template: '' }, - N8nButton: { - props: ['disabled'], - emits: ['click'], - template: - '', - }, - N8nIcon: { template: '' }, - N8nText: { template: '' }, - N8nTooltip: { template: '' }, - }, - }, - }); + const wrapper = mountSection([], {}, null, [], [], { sections: ['tools', 'skills'] }); await flushPromises(); - // Allowlisted rows present. + /** Allowlisted rows are present. */ expect(wrapper.find('[data-testid="agent-capabilities-add-tool"]').exists()).toBe(true); expect(wrapper.find('[data-testid="agent-capabilities-add-skill"]').exists()).toBe(true); - // Suppressed rows absent. + /** Suppressed rows are absent. */ expect(wrapper.find('[data-testid="agent-capabilities-add-sub-agent"]').exists()).toBe(false); - // The project-agents list (only needed for sub-agents) is not fetched. + /** The project-agent list is not needed for hidden sub-agents. */ expect(ensureProjectAgentsLoadedSpy).not.toHaveBeenCalled(); }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentWebSearchSection.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentWebSearchSection.test.ts new file mode 100644 index 00000000000..b99dc4a09a5 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentWebSearchSection.test.ts @@ -0,0 +1,559 @@ +/* eslint-disable import-x/no-extraneous-dependencies -- test-only pattern */ +import { beforeEach, describe, it, expect, vi } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { nextTick } from 'vue'; + +import AgentWebSearchSection from '../components/AgentWebSearchSection.vue'; +import AgentCredentialSelect from '../components/AgentCredentialSelect.vue'; +import type { AgentJsonConfig } from '../types'; + +const openNewCredentialMock = vi.hoisted(() => vi.fn()); + +type MockProject = { id: string; scopes: string[] }; +const CREDENTIAL_CREATE_SCOPES = ['credential:create']; +// Mutable per test (reset in beforeEach) so the project → permission resolution can be exercised. +const projectsStoreState = vi.hoisted(() => ({ + currentProject: null as MockProject | null, + personalProject: null as MockProject | null, + myProjects: [] as MockProject[], +})); + +vi.mock('@n8n/i18n', () => ({ + useI18n: () => ({ + baseText: (key: string) => + ({ + 'agents.builder.advanced.reasoning.hint': 'Let the model reason before responding.', + 'agents.builder.advanced.reasoning.unsupportedHint': + 'This model does not support reasoning', + 'agents.builder.advanced.reasoning.noModelHint': 'No model selected', + 'nodeCredentials.createNew': 'Create new credential', + 'nodeCredentials.createNew.permissionDenied': + 'Your current role does not allow you to create credentials', + })[key] ?? key, + }), +})); + +vi.mock('@/features/credentials/credentials.store', () => ({ + useCredentialsStore: () => ({ + allCredentials: [ + { id: 'brave-1', name: 'Brave Key', type: 'braveSearchApi' }, + { id: 'searxng-1', name: 'SearXNG', type: 'searXngApi' }, + ], + getCredentialTypeByName: (type: string) => ({ displayName: type }), + }), +})); + +vi.mock('@/features/collaboration/projects/projects.store', () => ({ + useProjectsStore: () => projectsStoreState, +})); + +vi.mock('@/app/stores/ui.store', () => ({ + useUIStore: () => ({ openNewCredential: openNewCredentialMock }), +})); + +vi.mock('@n8n/design-system', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + N8nDropdownMenu: { + name: 'N8nDropdownMenu', + props: ['items', 'disabled', 'placement'], + emits: ['select'], + template: '
', + }, + }; +}); + +const globalStubs = { + N8nIcon: { template: '', props: ['icon', 'size'] }, + N8nText: { template: '' }, + N8nInputNumber: { + props: ['modelValue', 'disabled', 'min', 'max', 'precision', 'placeholder'], + emits: ['update:modelValue'], + template: + '', + }, + /** Keep the select as one root and render its credential creation footer. */ + N8nSelect: { + props: ['modelValue', 'disabled'], + emits: ['update:modelValue'], + template: + '', + }, + N8nTooltip: { + props: ['disabled', 'content'], + template: + '', + }, + N8nOption: { + name: 'N8nOption', + props: ['value', 'label'], + template: '', + }, + Option: { + props: ['value', 'label'], + template: '', + }, + N8nSwitch2: { + props: ['modelValue', 'disabled'], + emits: ['update:modelValue'], + template: + '