diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts
index da44fc9e121..aafc59fd714 100644
--- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts
@@ -30,12 +30,14 @@ const {
fetchAllCredentialsMock,
fetchCredentialTypesMock,
setCredentialsMock,
+ fetchAllVariablesMock,
agentPermissionsMock,
} = vi.hoisted(() => ({
fetchAllCredentialsForWorkflowMock: vi.fn().mockResolvedValue(undefined),
fetchAllCredentialsMock: vi.fn().mockResolvedValue(undefined),
fetchCredentialTypesMock: vi.fn().mockResolvedValue(undefined),
setCredentialsMock: vi.fn(),
+ fetchAllVariablesMock: vi.fn().mockResolvedValue(undefined),
agentPermissionsMock: {
canCreate: { value: true },
canUpdate: { value: true },
@@ -85,6 +87,14 @@ vi.mock('@/features/credentials/credentials.store', () => ({
}),
}));
+vi.mock('@/features/settings/environments.ee/environments.store', () => ({
+ default: () => ({ fetchAllVariables: fetchAllVariablesMock }),
+}));
+
+vi.mock('../components/AgentExpressionAwareMarkdownEditor.vue', () => ({
+ default: { template: '
' },
+}));
+
vi.mock('@n8n/composables/useTelemetry', () => ({
useTelemetry: () => ({ track: vi.fn() }),
}));
@@ -329,7 +339,10 @@ async function renderView({
setActivePinia(pinia);
const { useSettingsStore } = await import('@/app/stores/settings.store');
const settingsStore = useSettingsStore();
- settingsStore.settings = { activeModules: knowledgeBaseEnabled ? ['agents'] : [] } as never;
+ settingsStore.settings = {
+ activeModules: knowledgeBaseEnabled ? ['agents'] : [],
+ enterprise: { variables: true },
+ } as never;
settingsStore.moduleSettings = {
agents: {
modules: [],
@@ -532,6 +545,7 @@ function resetViewMocks() {
uploadAgentFilesMock.mockReset();
uploadAgentFilesMock.mockResolvedValue([]);
showErrorMock.mockReset();
+ fetchAllVariablesMock.mockClear();
fetchConfigMock.mockClear();
builderTelemetryMock.fetchInitialTriggersBaseline.mockResolvedValue(null);
favoritesStoreMock.isFavorite.mockReturnValue(false);
@@ -545,7 +559,7 @@ describe('AgentBuilderView — preview routing', () => {
// 5s test timeout; warm the module once so each case measures mount behavior.
beforeAll(async () => {
await import('../views/AgentBuilderView.vue');
- }, 30_000);
+ }, 60_000);
beforeEach(() => {
resetViewMocks();
@@ -578,12 +592,13 @@ describe('AgentBuilderView — preview routing', () => {
expect(wrapper.find('[data-testid="agent-chat-mode-toggle"]').exists()).toBe(false);
});
- it('loads credentials through the workflow-scoped credentials endpoint for the agent project', async () => {
+ it('loads credentials and variables for the agent project', async () => {
await renderView();
expect(setCredentialsMock).toHaveBeenCalledWith([]);
expect(fetchAllCredentialsForWorkflowMock).toHaveBeenCalledWith({ projectId: 'p1' });
expect(fetchAllCredentialsMock).not.toHaveBeenCalled();
+ expect(fetchAllVariablesMock).toHaveBeenCalledOnce();
});
it('persists a generated personalisation gradient when an existing agent is missing one', async () => {
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentExpressionEditors.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentExpressionEditors.test.ts
new file mode 100644
index 00000000000..4b36db47150
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentExpressionEditors.test.ts
@@ -0,0 +1,120 @@
+import { createTestingPinia } from '@pinia/testing';
+import { mount } from '@vue/test-utils';
+import { defineComponent, nextTick } from 'vue';
+import { describe, expect, it, vi } from 'vitest';
+
+import AgentExpressionAwareMarkdownEditor from '../components/AgentExpressionAwareMarkdownEditor.vue';
+import AgentExpressionInput from '../components/AgentExpressionInput.vue';
+
+const { completionStatusMock } = vi.hoisted(() => ({ completionStatusMock: vi.fn() }));
+
+vi.mock('@codemirror/autocomplete', async (importOriginal) => ({
+ ...(await importOriginal()),
+ completionStatus: completionStatusMock,
+}));
+
+const expressionFocus = vi.fn();
+
+const ExpressionInputStub = defineComponent({
+ name: 'AgentExpressionInput',
+ props: ['modelValue', 'readonly', 'containerClass'],
+ setup(_, { expose }) {
+ expose({ focus: expressionFocus });
+ return {};
+ },
+ template: '',
+});
+
+const MarkdownEditorStub = defineComponent({
+ name: 'MarkdownEditor',
+ props: ['modelValue'],
+ emits: ['update:modelValue'],
+ template: '',
+});
+
+const InlineExpressionEditorInputStub = defineComponent({
+ name: 'InlineExpressionEditorInput',
+ props: ['modelValue', 'additionalData'],
+ emits: ['update:modelValue'],
+ setup(_, { expose }) {
+ expose({
+ editor: { state: { doc: { toString: () => '{{ $vars.latest }}' } } },
+ focus: vi.fn(),
+ });
+ },
+ template: '',
+});
+
+function mountAwareEditor(
+ modelValue: string,
+ props: { readonly?: boolean; containerClass?: string } = {},
+) {
+ return mount(AgentExpressionAwareMarkdownEditor, {
+ props: { modelValue, ...props },
+ global: {
+ stubs: {
+ AgentExpressionInput: ExpressionInputStub,
+ N8nMarkdownEditor: MarkdownEditorStub,
+ MarkdownEditor: MarkdownEditorStub,
+ },
+ },
+ });
+}
+
+describe('AgentExpressionAwareMarkdownEditor', () => {
+ it('keeps ordinary instructions in the Markdown editor and forwards updates', () => {
+ const wrapper = mountAwareEditor('# Role\nHelp users.');
+ const markdown = wrapper.findComponent(MarkdownEditorStub);
+
+ expect(markdown.props('modelValue')).toBe('# Role\nHelp users.');
+ markdown.vm.$emit('update:modelValue', '# Role\nBe concise.');
+
+ expect(wrapper.emitted('update:modelValue')).toEqual([['# Role\nBe concise.']]);
+ });
+
+ it('switches at a leading equals sign and preserves focus', async () => {
+ expressionFocus.mockClear();
+ const wrapper = mountAwareEditor('', { readonly: true, containerClass: 'fill-height' });
+
+ wrapper.findComponent(MarkdownEditorStub).vm.$emit('update:modelValue', '=');
+ await nextTick();
+
+ expect(wrapper.findComponent(ExpressionInputStub).props()).toMatchObject({
+ modelValue: '=',
+ readonly: true,
+ containerClass: 'fill-height',
+ });
+ expect(expressionFocus).toHaveBeenCalledOnce();
+ });
+});
+
+describe('AgentExpressionInput', () => {
+ it('adapts raw values and submits the current document after completion closes', async () => {
+ completionStatusMock.mockReturnValue(null);
+ const wrapper = mount(AgentExpressionInput, {
+ props: { modelValue: '={{ $vars.team }}', submitOnEnter: true },
+ global: {
+ plugins: [createTestingPinia()],
+ stubs: { InlineExpressionEditorInput: InlineExpressionEditorInputStub },
+ },
+ });
+ const inline = wrapper.findComponent(InlineExpressionEditorInputStub);
+
+ expect(inline.props('modelValue')).toBe('{{ $vars.team }}');
+ expect(inline.props('additionalData')).toHaveProperty('$vars');
+
+ inline.vm.$emit('update:modelValue', {
+ value: '={{ $vars.department }}',
+ segments: [],
+ });
+
+ expect(wrapper.emitted('update:modelValue')).toEqual([['={{ $vars.department }}']]);
+
+ await inline.trigger('keydown', { key: 'Enter' });
+ expect(wrapper.emitted('submit')).toEqual([['={{ $vars.latest }}']]);
+
+ completionStatusMock.mockReturnValue('active');
+ await inline.trigger('keydown', { key: 'Enter' });
+ expect(wrapper.emitted('submit')).toHaveLength(1);
+ });
+});
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentInfoPanel.spec.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentInfoPanel.spec.ts
index bcb22875357..42b416f6098 100644
--- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentInfoPanel.spec.ts
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentInfoPanel.spec.ts
@@ -57,16 +57,18 @@ vi.mock('@n8n/i18n', () => ({
}));
vi.mock('@n8n/design-system', () => ({
- N8nMarkdownEditor: {
- name: 'N8nMarkdownEditor',
- props: ['modelValue', 'variant', 'showToolbar', 'placeholder', 'readonly', 'maxHeight'],
- emits: ['update:modelValue'],
- template:
- '{{ modelValue }} {{ placeholder }}
',
- },
N8nText: { template: '', props: ['tag', 'bold', 'size', 'color'] },
}));
+vi.mock('../components/AgentExpressionAwareMarkdownEditor.vue', () => ({
+ default: {
+ name: 'AgentExpressionAwareMarkdownEditor',
+ props: ['modelValue', 'variant', 'showToolbar', 'maxHeight', 'disabled'],
+ emits: ['update:modelValue'],
+ template: '{{ modelValue }}
',
+ },
+}));
+
vi.mock('@/app/composables/useToast', () => ({
useToast: () => ({ showError: vi.fn() }),
}));
@@ -182,7 +184,7 @@ describe('AgentInfoPanel', () => {
it('renders instructions as a contained markdown editor', () => {
const wrapper = mountPanel();
- const editor = wrapper.findComponent({ name: 'N8nMarkdownEditor' });
+ const editor = wrapper.findComponent({ name: 'AgentExpressionAwareMarkdownEditor' });
expect(editor.props()).toMatchObject({
modelValue: '# Role\nHelp users.',
variant: 'contained',
@@ -198,7 +200,7 @@ describe('AgentInfoPanel', () => {
it('can show the markdown toolbar above instructions', () => {
const wrapper = mountPanel('# Role\nHelp users.', { showInstructionsToolbar: true });
- const editor = wrapper.findComponent({ name: 'N8nMarkdownEditor' });
+ const editor = wrapper.findComponent({ name: 'AgentExpressionAwareMarkdownEditor' });
expect(editor.props()).toMatchObject({
showToolbar: 'always',
variant: 'contained',
@@ -208,7 +210,7 @@ describe('AgentInfoPanel', () => {
it('does not pass placeholder text to the instructions editor', () => {
const wrapper = mountPanel('');
- const editor = wrapper.findComponent({ name: 'N8nMarkdownEditor' });
+ const editor = wrapper.findComponent({ name: 'AgentExpressionAwareMarkdownEditor' });
expect(editor.props('modelValue')).toBe('');
expect(editor.props('placeholder')).toBeUndefined();
expect(wrapper.text()).not.toContain('Enter instructions here');
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillViewer.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillViewer.test.ts
index eac16d3c1ac..ba161a82de3 100644
--- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillViewer.test.ts
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentSkillViewer.test.ts
@@ -9,6 +9,15 @@ import {
import AgentSkillViewer from '../components/AgentSkillViewer.vue';
+vi.mock('../components/AgentExpressionAwareMarkdownEditor.vue', () => ({
+ default: {
+ name: 'AgentExpressionAwareMarkdownEditor',
+ props: ['modelValue', 'readonly', 'path', 'showToolbar'],
+ emits: ['update:modelValue'],
+ template: '',
+ },
+}));
+
vi.mock('@n8n/i18n', () => ({
useI18n: () => ({
baseText: (key: string, options?: { interpolate?: Record }) => {
@@ -44,6 +53,37 @@ const N8nFormInputStub = defineComponent({
});
describe('AgentSkillViewer', () => {
+ it('uses expression-aware editing only for the SKILL.md instructions', () => {
+ const skill = {
+ name: 'Research',
+ description: 'Use for research',
+ instructions: 'Main body',
+ references: [{ path: 'references/guide.md', content: '# Guide' }],
+ };
+ const instructions = mountViewer({ skill, disabled: true });
+ const editor = instructions.findComponent({ name: 'AgentExpressionAwareMarkdownEditor' });
+ expect(editor.props()).toMatchObject({
+ readonly: true,
+ path: 'agent.skill.instructions',
+ showToolbar: 'always',
+ });
+ editor.vm.$emit('update:modelValue', '={{ $vars.skill }}');
+
+ expect(instructions.emitted('update:skill')?.at(-1)).toEqual([
+ { instructions: '={{ $vars.skill }}' },
+ ]);
+
+ const reference = mountViewer({
+ skill,
+ selectedPath: 'references/guide.md',
+ });
+
+ expect(reference.findComponent({ name: 'AgentExpressionAwareMarkdownEditor' }).exists()).toBe(
+ false,
+ );
+ expect(reference.find('textarea').element.value).toBe('# Guide');
+ });
+
it('imports SKILL.md frontmatter and instructions', async () => {
const wrapper = mount(AgentSkillViewer, {
props: {
diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentTelegramAccessSettingsForm.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentTelegramAccessSettingsForm.test.ts
new file mode 100644
index 00000000000..2e13a5f7228
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentTelegramAccessSettingsForm.test.ts
@@ -0,0 +1,97 @@
+/* eslint-disable import-x/no-extraneous-dependencies -- test-only pattern */
+import { mount } from '@vue/test-utils';
+import { describe, expect, it, vi } from 'vitest';
+
+import AgentTelegramAccessSettingsForm from '../components/AgentTelegramAccessSettingsForm.vue';
+
+vi.mock('@n8n/i18n', () => ({
+ useI18n: () => ({ baseText: (key: string) => key }),
+ i18n: { baseText: (key: string) => key },
+}));
+
+const selectStub = {
+ props: ['modelValue', 'disabled'],
+ emits: ['update:modelValue'],
+ template: `
+
+ `,
+};
+
+const expressionInputStub = {
+ name: 'AgentExpressionInput',
+ props: ['modelValue', 'disabled', 'rows', 'embedded', 'submitOnEnter'],
+ emits: ['update:modelValue', 'blur', 'submit'],
+ methods: { focus: vi.fn() },
+ template: '',
+};
+
+function mountForm(
+ savedSettings: { accessMode: 'private' | 'public'; allowedUsers: string[] } = {
+ accessMode: 'private',
+ allowedUsers: [],
+ },
+) {
+ return mount(AgentTelegramAccessSettingsForm, {
+ props: { savedSettings },
+ global: {
+ stubs: {
+ AgentExpressionInput: expressionInputStub,
+ N8nCallout: { template: '
' },
+ N8nIcon: { template: '' },
+ N8nOption: { template: '' },
+ N8nSelect: selectStub,
+ N8nText: { template: '' },
+ },
+ },
+ });
+}
+
+describe('AgentTelegramAccessSettingsForm', () => {
+ it('preserves a loaded expression as valid saved state', () => {
+ const expression = '={{ $vars.TELEGRAM_USER_ID }}';
+ const wrapper = mountForm({ accessMode: 'private', allowedUsers: [expression] });
+
+ expect(wrapper.vm.currentSettings.allowedUsers).toEqual([expression]);
+ expect(wrapper.vm.validationError).toBeNull();
+ expect(wrapper.vm.isDirty).toBe(false);
+ });
+
+ it('switches to autocomplete at = and commits the complete expression as one user', async () => {
+ const wrapper = mountForm();
+ const input = wrapper.get('#telegram-user-ids-input');
+ const expression = '={{ $vars.TELEGRAM_USER_ID }}';
+
+ await input.setValue('=');
+ const expressionInput = wrapper.getComponent(expressionInputStub);
+ expect(expressionInput.props()).toMatchObject({ modelValue: '=', rows: 1 });
+
+ await expressionInput.trigger('keydown', { key: ' ' });
+ expect(wrapper.vm.currentSettings.allowedUsers).toEqual([]);
+
+ const paste = new Event('paste', { bubbles: true, cancelable: true });
+ Object.defineProperty(paste, 'clipboardData', {
+ value: { getData: () => '{{ $vars.PASTED_ID }}' },
+ });
+ expressionInput.element.dispatchEvent(paste);
+ expect(wrapper.vm.currentSettings.allowedUsers).toEqual([]);
+
+ await expressionInput.vm.$emit('submit', expression);
+ expect(wrapper.vm.currentSettings.allowedUsers).toEqual([expression]);
+ });
+
+ it('retains literal bulk entry behavior', async () => {
+ const wrapper = mountForm();
+ const input = wrapper.get('#telegram-user-ids-input');
+
+ await input.setValue('@alice, 123 @bob');
+ await input.trigger('blur');
+
+ expect(wrapper.vm.currentSettings.allowedUsers).toEqual(['@alice', '123', '@bob']);
+ });
+});
diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentExpressionAwareMarkdownEditor.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentExpressionAwareMarkdownEditor.vue
new file mode 100644
index 00000000000..8d7e0924cbc
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/agents/components/AgentExpressionAwareMarkdownEditor.vue
@@ -0,0 +1,102 @@
+
+
+
+
+
+
diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentExpressionInput.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentExpressionInput.vue
new file mode 100644
index 00000000000..d94c9a6b43c
--- /dev/null
+++ b/packages/frontend/editor-ui/src/features/agents/components/AgentExpressionInput.vue
@@ -0,0 +1,122 @@
+
+
+
+
+
+
+
diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentInfoPanel.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentInfoPanel.vue
index 105a51dc2f9..62e7462ced8 100644
--- a/packages/frontend/editor-ui/src/features/agents/components/AgentInfoPanel.vue
+++ b/packages/frontend/editor-ui/src/features/agents/components/AgentInfoPanel.vue
@@ -6,7 +6,7 @@
*/
import { computed, ref, watch } from 'vue';
import { useDebounceFn } from '@vueuse/core';
-import { N8nMarkdownEditor, N8nText } from '@n8n/design-system';
+import { N8nText } from '@n8n/design-system';
import { AI_GATEWAY_MANAGED_TAG } from '@n8n/api-types';
import { useI18n } from '@n8n/i18n';
@@ -33,6 +33,7 @@ import { normalizePromptCachingForModelChange } from '../utils/promptCaching';
import { normalizeReasoningForModelChange } from '../utils/reasoning';
import AgentModelSelector from './AgentModelSelector.vue';
import AgentPanelHeader from './AgentPanelHeader.vue';
+import AgentExpressionAwareMarkdownEditor from './AgentExpressionAwareMarkdownEditor.vue';
const props = withDefaults(
defineProps<{
@@ -244,7 +245,7 @@ function onInstructionsInput(value: string) {
i18n.baseText('agents.builder.agent.instructions.label')
}}
- emit('update:valid', valid), { immediate: true });
:required="true"
size="small"
>
-
-import { ref, computed, watch, nextTick } from 'vue';
+import { computed, nextTick, ref, watch } from 'vue';
import { N8nCallout, N8nIcon, N8nOption, N8nSelect, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
-import type { AgentTelegramIntegrationSettings } from '@n8n/api-types';
+import {
+ AgentTelegramAllowedUserSchema,
+ type AgentTelegramIntegrationSettings,
+} from '@n8n/api-types';
+import { isExpression } from 'n8n-workflow';
+import AgentExpressionInput from './AgentExpressionInput.vue';
import {
DEFAULT_TELEGRAM_PUBLIC_SETTINGS,
- VALID_TELEGRAM_ENTRY_RE,
+ normalizeTelegramUsers,
type TelegramSettingsValidationError,
} from '../utils/telegramAccessSettings';
@@ -31,6 +36,7 @@ const accessMode = ref(
const entries = ref(props.savedSettings?.allowedUsers.slice() ?? []);
const inputText = ref('');
const inputRef = ref();
+const expressionInputRef = ref>();
watch(
() => props.savedSettings,
@@ -42,43 +48,54 @@ watch(
},
);
-function finalizeInput() {
- const raw = inputText.value.trim();
- if (!raw) return;
+function focusInput() {
+ if (isExpression(inputText.value)) expressionInputRef.value?.focus();
+ else inputRef.value?.focus();
+}
- const tokens = raw.split(/[\s,]+/).filter(Boolean);
- const unique = new Set(entries.value);
- for (const token of tokens) {
- unique.add(token);
- }
- entries.value = [...unique];
+function updateInputText(value: string) {
+ const expressionModeChanged = isExpression(value) !== isExpression(inputText.value);
+ inputText.value = value;
+ if (expressionModeChanged) void nextTick(focusInput);
+}
+
+function onInput(event: Event) {
+ if (event.target instanceof HTMLInputElement) updateInputText(event.target.value);
+}
+
+function finalizeInput(value = inputText.value) {
+ const tokens = normalizeTelegramUsers([value]);
+ if (tokens.length === 0) return;
+
+ entries.value = [...new Set([...entries.value, ...tokens])];
inputText.value = '';
}
function removeEntry(index: number) {
entries.value = entries.value.filter((_, i) => i !== index);
- void nextTick(() => inputRef.value?.focus());
+ void nextTick(focusInput);
}
-function onKeydown(e: KeyboardEvent) {
- if (e.key === ',' || e.key === ' ' || e.key === 'Enter') {
- e.preventDefault();
+function onKeydown(event: KeyboardEvent) {
+ if (event.defaultPrevented) return;
+ const separator = event.key === ',' || event.key === ' ' || event.key === 'Enter';
+ if (separator && (!isExpression(inputText.value) || event.key === 'Enter')) {
+ event.preventDefault();
finalizeInput();
}
- if (e.key === 'Backspace' && inputText.value === '' && entries.value.length > 0) {
+ if (event.key === 'Backspace' && inputText.value === '' && entries.value.length > 0) {
entries.value = entries.value.slice(0, -1);
}
}
-function onPaste(e: ClipboardEvent) {
- e.preventDefault();
- const pasted = e.clipboardData?.getData('text') ?? '';
- inputText.value += pasted;
+function onPaste(event: ClipboardEvent) {
+ event.preventDefault();
+ updateInputText(inputText.value + (event.clipboardData?.getData('text') ?? ''));
finalizeInput();
}
-function onContainerClick() {
- inputRef.value?.focus();
+function isValidEntry(entry: string) {
+ return AgentTelegramAllowedUserSchema.safeParse(entry).success;
}
const currentSettings = computed(() => ({
@@ -87,7 +104,7 @@ const currentSettings = computed(() => ({
}));
const invalidEntries = computed(() =>
- entries.value.filter((entry) => !VALID_TELEGRAM_ENTRY_RE.test(entry)),
+ entries.value.filter((entry) => !isValidEntry(entry)),
);
const validationError = computed(() => {
@@ -148,12 +165,12 @@ defineExpose({ currentSettings, validationError, isDirty });
entry.trim())
- .filter(Boolean);
+ const rawEntries = splitTelegramUsersInput(input);
const validEntries: string[] = [];
const invalidEntries: string[] = [];
for (const entry of rawEntries) {
- if (VALID_TELEGRAM_ENTRY_RE.test(entry)) {
- validEntries.push(entry);
+ const result = AgentTelegramAllowedUserSchema.safeParse(entry);
+ if (result.success) {
+ validEntries.push(result.data);
} else {
invalidEntries.push(entry);
}
diff --git a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue
index 3e433f46b85..bb511a6c3d0 100644
--- a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue
+++ b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue
@@ -25,10 +25,11 @@ import { useTelemetry } from '@n8n/composables/useTelemetry';
import { useToast } from '@/app/composables/useToast';
import { useCredentialsStore } from '@/features/credentials/credentials.store';
import { useSettingsStore } from '@/app/stores/settings.store';
+import useEnvironmentsStore from '@/features/settings/environments.ee/environments.store';
import { useUIStore } from '@/app/stores/ui.store';
import { useFavoritesStore } from '@/app/stores/favorites.store';
import { useDocumentTitle } from '@/app/composables/useDocumentTitle';
-import { MODAL_CONFIRM } from '@/app/constants';
+import { EnterpriseEditionFeature, MODAL_CONFIRM } from '@/app/constants';
import { deepCopy } from 'n8n-workflow';
import {
getAgent,
@@ -110,6 +111,7 @@ const { canSendPreviewToInstanceAi, sendPreviewSessionToInstanceAi } =
const sessionsStore = useAgentSessionsStore();
const credentialsStore = useCredentialsStore();
const settingsStore = useSettingsStore();
+const environmentsStore = useEnvironmentsStore();
const uiStore = useUIStore();
const favoritesStore = useFavoritesStore();
const mcpStore = useMCPStore();
@@ -1180,6 +1182,9 @@ async function initialize() {
fetchConfig(projectId.value, agentId.value),
fetchAgentFiles(),
refreshConfigValidation(projectId.value, agentId.value),
+ settingsStore.isEnterpriseFeatureEnabled[EnterpriseEditionFeature.Variables]
+ ? environmentsStore.fetchAllVariables().catch(() => undefined)
+ : Promise.resolve(),
]);
persistMissingPersonalisationGradient();
builderTelemetry.captureToolsBaseline();
diff --git a/packages/frontend/editor-ui/src/features/ndv/agents/components/AgentNdvInlineControls.vue b/packages/frontend/editor-ui/src/features/ndv/agents/components/AgentNdvInlineControls.vue
index c4416278c2c..6e0407783b0 100644
--- a/packages/frontend/editor-ui/src/features/ndv/agents/components/AgentNdvInlineControls.vue
+++ b/packages/frontend/editor-ui/src/features/ndv/agents/components/AgentNdvInlineControls.vue
@@ -11,11 +11,12 @@
* features — the builder banner above nudges users there.
*/
import { computed, inject } from 'vue';
-import { N8nMarkdownEditor, N8nSectionHeader, N8nText } from '@n8n/design-system';
+import { N8nSectionHeader, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import AgentInfoPanel from '@/features/agents/components/AgentInfoPanel.vue';
import AgentCapabilitiesSection from '@/features/agents/components/AgentCapabilitiesSection.vue';
+import AgentExpressionAwareMarkdownEditor from '@/features/agents/components/AgentExpressionAwareMarkdownEditor.vue';
import { NdvAgentConfigKey } from '../composables/useNdvAgentConfig';
@@ -70,9 +71,10 @@ function onInstructionsUpdate(instructions: string) {
{{ i18n.baseText('agents.builder.agent.instructions.label') }}
- ',
@@ -109,7 +109,7 @@ function mountControls(ndv: UseNdvAgentConfigReturn, props: { isReadOnly?: boole
stubs: {
AgentCapabilitiesSection: CapabilitiesStub,
AgentInfoPanel: InfoPanelStub,
- MarkdownEditor: MarkdownEditorStub,
+ AgentExpressionAwareMarkdownEditor: MarkdownEditorStub,
},
},
});
diff --git a/packages/frontend/editor-ui/src/features/shared/editors/plugins/codemirror/completions/completions.test.ts b/packages/frontend/editor-ui/src/features/shared/editors/plugins/codemirror/completions/completions.test.ts
index 601edbdf92c..09c32f4051f 100644
--- a/packages/frontend/editor-ui/src/features/shared/editors/plugins/codemirror/completions/completions.test.ts
+++ b/packages/frontend/editor-ui/src/features/shared/editors/plugins/codemirror/completions/completions.test.ts
@@ -106,6 +106,12 @@ describe('No completions', () => {
});
describe('Top-level completions', () => {
+ test('should only offer variables without an active node', async () => {
+ vi.spyOn(utils, 'hasActiveNode').mockReturnValue(false);
+
+ expect(await completions('{{ $| }}')).toEqual([expect.objectContaining({ label: '$vars' })]);
+ });
+
test('should return dollar completions for blank position: {{ | }}', async () => {
const result = await completions('{{ | }}');
expect(result).toHaveLength(19);
diff --git a/packages/frontend/editor-ui/src/features/shared/editors/plugins/codemirror/completions/dollar.completions.ts b/packages/frontend/editor-ui/src/features/shared/editors/plugins/codemirror/completions/dollar.completions.ts
index ba126230a9f..59866d197e8 100644
--- a/packages/frontend/editor-ui/src/features/shared/editors/plugins/codemirror/completions/dollar.completions.ts
+++ b/packages/frontend/editor-ui/src/features/shared/editors/plugins/codemirror/completions/dollar.completions.ts
@@ -126,7 +126,7 @@ export async function dollarOptions(context: CompletionContext): Promise label === '$vars');
}
if (await receivesNoBinaryData(workflowDocumentId, targetNodeParameterContext?.nodeName))