diff --git a/packages/@n8n/api-types/src/agent-builder-interactive.ts b/packages/@n8n/api-types/src/agent-builder-interactive.ts index 5ca6c6ee48d..85ef6f9f51d 100644 --- a/packages/@n8n/api-types/src/agent-builder-interactive.ts +++ b/packages/@n8n/api-types/src/agent-builder-interactive.ts @@ -94,7 +94,10 @@ export const askQuestionInputSchema = z.object({ }); export const askQuestionResumeSchema = z.object({ - values: z.array(z.string()).min(1), + values: z + .array(z.string()) + .min(1) + .describe('Selected option values, or freeform text entered in the Other field.'), }); export type AskQuestionOption = z.infer; diff --git a/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts b/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts index db4b6259ac6..b359f226125 100644 --- a/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts +++ b/packages/cli/src/modules/agents/builder/agents-builder-prompts.ts @@ -150,6 +150,9 @@ When: the user must choose a model/credential because the request is ambiguous, resolve_llm returned an ambiguous/missing credential result, or the user asks to pick/change/use a different model. Call AT MOST ONCE per build turn unless the user changes their mind. +Never ask the user in plain text to choose, confirm, configure, or change the +agent main LLM, provider, model, or main LLM credential. If the user needs to +make that choice, call ask_llm so the picker card is shown. Returns: { provider, model, credentialId, credentialName }. After: set \`model = "{provider}/{model}"\` and \`credential = credentialName\` via write_config or patch_config. @@ -172,8 +175,9 @@ When: you would otherwise ask a clarifying question whose answer is one (or more) of a known list. Examples: pick a Slack channel from a list, read-only vs read-write, which workflow to wrap. Inputs: \`question\`, \`options[{label,value,description?}]\`, \`allowMultiple?\`. -Returns: { values: string[] }. Do NOT call ask_question for free-text input; -ask in prose for that. +Returns: { values: string[] }. Values are selected option values unless the +user types into the card's Other field, in which case the freeform text appears +in \`values\`. ### Rules - Never call two interactive tools in parallel. The run suspends on the first. @@ -223,6 +227,8 @@ list_credentials. Pick the action by reason: Rules: - Explicit provider/model request → resolve_llm first, not ask_llm. - User asks to pick/change/use a different model → ask_llm. +- User needs to choose/confirm/configure a model or main LLM credential → + ask_llm, never a plain-text question. - No provider specified and resolve_llm reports ambiguity → ask_llm.`; export const N8N_EXPRESSIONS_SECTION = `\ @@ -425,6 +431,8 @@ export const WORKFLOW_SECTION = `\ resolve_llm reports ambiguity, or the user asks to choose/change/use a different model, call ask_llm. Then call read_config and write_config with the chosen \`model\` and \`credential\` plus a draft \`instructions\`. + Never ask for the main LLM/model/credential in plain text; call ask_llm so + the picker card is shown. 2. Use ask_question whenever you have a clarifying question with discrete options (e.g. "Which Slack channel?" → list channels, "Read-only or read-write?"). Never put the question in plain text if options are known. diff --git a/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-llm.tool.test.ts b/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-llm.tool.test.ts index ce0a7883678..d22ebe5e06d 100644 --- a/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-llm.tool.test.ts +++ b/packages/cli/src/modules/agents/builder/interactive/__tests__/ask-llm.tool.test.ts @@ -10,6 +10,12 @@ function makeCtx(overrides?: { resumeData?: unknown }): TestCtx { } describe('ask_llm tool', () => { + it('instructs the builder to render the picker instead of asking in prose', () => { + const tool = buildAskLlmTool(); + expect(tool.systemInstruction).toContain('Never ask the user in plain text'); + expect(tool.systemInstruction).toContain('call ask_llm'); + }); + it('suspends on first invocation so the user can choose', async () => { const tool = buildAskLlmTool(); const ctx = makeCtx(); diff --git a/packages/cli/src/modules/agents/builder/interactive/ask-llm.tool.ts b/packages/cli/src/modules/agents/builder/interactive/ask-llm.tool.ts index 0e10cb98586..2e595679f22 100644 --- a/packages/cli/src/modules/agents/builder/interactive/ask-llm.tool.ts +++ b/packages/cli/src/modules/agents/builder/interactive/ask-llm.tool.ts @@ -18,6 +18,11 @@ export function buildAskLlmTool(): BuiltTool { 'After resume: set model = "{provider}/{model}" and credential = credentialName ' + 'via write_config or patch_config.', ) + .systemInstruction( + 'Never ask the user in plain text to choose, confirm, configure, or change the agent ' + + 'main LLM, provider, model, or main LLM credential. If the user needs to make that ' + + 'choice, call ask_llm so the picker card is shown.', + ) .input(askLlmInputSchema) .suspend(askLlmInputSchema) .resume(askLlmResumeSchema) diff --git a/packages/cli/src/modules/agents/builder/interactive/ask-question.tool.ts b/packages/cli/src/modules/agents/builder/interactive/ask-question.tool.ts index c49f0915d09..a89a1e6bad5 100644 --- a/packages/cli/src/modules/agents/builder/interactive/ask-question.tool.ts +++ b/packages/cli/src/modules/agents/builder/interactive/ask-question.tool.ts @@ -13,8 +13,9 @@ export function buildAskQuestionTool(): BuiltTool { .description( 'Show a multiple-choice card in the chat UI and suspend until the user picks an ' + 'answer. Use when the request is ambiguous and the answer is one (or more) of a ' + - 'known list of options. Do NOT use for free-text input — ask in prose for that. ' + - 'Returns { values: string[] } with the selected values.', + 'known list of options. The UI also includes an Other field, so returned values ' + + 'may include user-entered freeform text when the listed options are incomplete. ' + + 'Returns { values: string[] } with selected option values and/or Other text.', ) .input(askQuestionInputSchema) .suspend(askQuestionInputSchema) diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index 5dc28ca60ae..cc5b503d197 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -5741,6 +5741,8 @@ "agents.chat.loadHistory.error": "Could not load chat history", "agents.chat.clearHistory.error": "Could not clear chat history", "agents.chat.clearHistory": "Clear chat history", + "agents.chat.input.placeholder": "Type a message...", + "agents.chat.answerQuestionPlaceholder": "Answer the question above to continue", "agents.chat.misconfigured.title": "This agent isn't ready to run yet", "agents.chat.misconfigured.missingPrefix": "Missing:", "agents.chat.misconfigured.missing.instructions": "Instructions", @@ -5751,6 +5753,9 @@ "agents.chat.misconfigured.openBuild": "Finish setup in Build", "agents.chat.misconfigured.dismiss": "Dismiss", "agents.chat.askCredential.skip": "Skip", + "agents.chat.askQuestion.otherLabel": "Other", + "agents.chat.askQuestion.otherPlaceholder": "Type another answer", + "agents.chat.askQuestion.submit": "Submit", "agents.list.published": "Published", "agents.list.noDescription": "No description", "agents.list.updatedAt": "Updated {date}", diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts index bddeec2f56d..4498ae809ba 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentChatPanel.test.ts @@ -1,10 +1,20 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { flushPromises, mount } from '@vue/test-utils'; import { computed, ref } from 'vue'; +import { + ASK_CREDENTIAL_TOOL_NAME, + ASK_LLM_TOOL_NAME, + ASK_QUESTION_TOOL_NAME, + type InteractiveToolName, +} from '@n8n/api-types'; +import type { ChatMessage } from '../composables/agentChatMessages'; +import AgentChatPanel from '../components/AgentChatPanel.vue'; const sendMessageMock = vi.fn(); const stopGeneratingMock = vi.fn(); const loadHistoryMock = vi.fn(); +const messagesMock = ref([]); +const isStreamingMock = ref(false); vi.mock('@n8n/i18n', () => ({ useI18n: () => ({ baseText: (key: string) => key }), @@ -36,9 +46,9 @@ vi.mock('../components/AgentChatMessageList.vue', () => ({ vi.mock('../composables/useAgentChatStream', () => ({ useAgentChatStream: () => ({ - messages: ref([]), - isStreaming: ref(false), - messagingState: computed(() => 'idle'), + messages: messagesMock, + isStreaming: isStreamingMock, + messagingState: computed(() => (isStreamingMock.value ? 'receiving' : 'idle')), fatalError: ref(null), loadHistory: loadHistoryMock, sendMessage: sendMessageMock, @@ -67,8 +77,65 @@ vi.mock('../composables/agentTelemetry.utils', () => ({ describe('AgentChatPanel', () => { beforeEach(() => { vi.clearAllMocks(); + messagesMock.value = []; + isStreamingMock.value = false; }); + function mountPanel() { + return mount(AgentChatPanel, { + props: { + projectId: 'p1', + agentId: 'a1', + endpoint: 'build', + agentConfig: { + name: 'Agent', + model: 'anthropic/claude-sonnet-4-5', + instructions: 'Help.', + }, + agentStatus: 'draft', + connectedTriggers: [], + }, + }); + } + + function openInteractiveMessage( + toolName: InteractiveToolName = ASK_QUESTION_TOOL_NAME, + ): ChatMessage { + return { + id: 'assistant-1', + role: 'assistant', + content: '', + status: 'awaitingUser', + interactive: + toolName === ASK_QUESTION_TOOL_NAME + ? { + toolName: ASK_QUESTION_TOOL_NAME, + toolCallId: 'tc-1', + runId: 'run-1', + input: { + question: 'Pick one', + options: [{ label: 'Slack', value: 'slack' }], + }, + } + : toolName === ASK_LLM_TOOL_NAME + ? { + toolName: ASK_LLM_TOOL_NAME, + toolCallId: 'tc-1', + runId: 'run-1', + input: { purpose: 'Choose a model' }, + } + : { + toolName: ASK_CREDENTIAL_TOOL_NAME, + toolCallId: 'tc-1', + runId: 'run-1', + input: { + purpose: 'Choose Slack credentials', + credentialType: 'slackApi', + }, + }, + }; + } + it('awaits beforeSend before sending a build message', async () => { const events: string[] = []; let resolveBeforeSend: () => void = () => {}; @@ -85,7 +152,6 @@ describe('AgentChatPanel', () => { events.push('sendMessage'); }); - const { default: AgentChatPanel } = await import('../components/AgentChatPanel.vue'); const wrapper = mount(AgentChatPanel, { props: { projectId: 'p1', @@ -120,7 +186,6 @@ describe('AgentChatPanel', () => { it('does not consume an initial message when beforeSend fails', async () => { const beforeSend = vi.fn().mockRejectedValue(new Error('flush failed')); - const { default: AgentChatPanel } = await import('../components/AgentChatPanel.vue'); const wrapper = mount(AgentChatPanel, { props: { projectId: 'p1', @@ -144,4 +209,60 @@ describe('AgentChatPanel', () => { expect(sendMessageMock).not.toHaveBeenCalled(); expect(wrapper.emitted('initial-consumed')).toBeUndefined(); }); + + it('disables chat and blocks sending while an interactive question is unresolved', async () => { + messagesMock.value = [openInteractiveMessage()]; + + const wrapper = mountPanel(); + const chatInput = wrapper.findComponent({ name: 'ChatInputBase' }); + + expect(chatInput.props('disabled')).toBe(true); + expect(chatInput.props('canSubmit')).toBe(false); + expect(chatInput.props('placeholder')).toBe('agents.chat.answerQuestionPlaceholder'); + + ( + wrapper.vm as unknown as { sendMessageFromOutside: (message: string) => void } + ).sendMessageFromOutside('answer through chat'); + await flushPromises(); + + expect(sendMessageMock).not.toHaveBeenCalled(); + }); + + it('keeps chat enabled when the interactive card is resolved', () => { + messagesMock.value = [ + { + ...openInteractiveMessage(), + status: 'success', + interactive: { + toolName: ASK_QUESTION_TOOL_NAME, + toolCallId: 'tc-1', + resolvedAt: 1, + input: { + question: 'Pick one', + options: [{ label: 'Slack', value: 'slack' }], + }, + resolvedValue: { values: ['slack'] }, + }, + }, + ]; + + const wrapper = mountPanel(); + const chatInput = wrapper.findComponent({ name: 'ChatInputBase' }); + + expect(chatInput.props('disabled')).toBe(false); + expect(chatInput.props('placeholder')).toBe('agents.chat.input.placeholder'); + }); + + it.each([ASK_LLM_TOOL_NAME, ASK_CREDENTIAL_TOOL_NAME])( + 'disables chat while %s is unresolved', + (toolName) => { + messagesMock.value = [openInteractiveMessage(toolName)]; + + const wrapper = mountPanel(); + const chatInput = wrapper.findComponent({ name: 'ChatInputBase' }); + + expect(chatInput.props('disabled')).toBe(true); + expect(chatInput.props('canSubmit')).toBe(false); + }, + ); }); diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AskQuestionCard.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AskQuestionCard.test.ts index eade4433822..3d29077f272 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AskQuestionCard.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AskQuestionCard.test.ts @@ -26,6 +26,15 @@ function mountCard(props = {}) { template: '', }, + N8nInput: { + props: ['modelValue', 'disabled', 'placeholder'], + template: + '', + }, + N8nInputLabel: { + props: ['label'], + template: '', + }, N8nText: { template: '

' }, }, }, @@ -43,6 +52,7 @@ describe('AskQuestionCard', () => { expect(wrapper.text()).toContain('Option A'); expect(wrapper.text()).toContain('Option B'); expect(wrapper.text()).toContain('Extra info'); + expect(wrapper.find('[data-testid="ask-question-other-input"]').exists()).toBe(true); }); it('emits submit with selected value after clicking a single-choice option', async () => { @@ -86,9 +96,20 @@ describe('AskQuestionCard', () => { const wrapper = mountCard({ disabled: true }); const buttons = wrapper.findAll('button[aria-pressed]'); await buttons[0].trigger('click'); + await wrapper.find('[data-testid="ask-question-other-input"]').setValue('Different option'); + await wrapper.find('[data-testid="ask-question-other-submit"]').trigger('click'); expect(wrapper.emitted('submit')).toBeFalsy(); }); + it('submits typed Other text in single-choice mode', async () => { + const wrapper = mountCard(); + await wrapper.find('[data-testid="ask-question-other-input"]').setValue('Use Microsoft Teams'); + await wrapper.find('[data-testid="ask-question-other-submit"]').trigger('click'); + + const emitted = wrapper.emitted('submit') as unknown[][]; + expect(emitted[0][0]).toEqual({ values: ['Use Microsoft Teams'] }); + }); + it('allows selecting multiple values when allowMultiple=true', async () => { const wrapper = mountCard({ allowMultiple: true }); const checkboxes = wrapper.findAll('[data-testid="ask-question-checkbox"]'); @@ -102,4 +123,24 @@ describe('AskQuestionCard', () => { const emitted = wrapper.emitted('submit') as unknown[][]; expect(emitted[0][0]).toEqual({ values: ['a', 'b'] }); }); + + it('submits selected multiple values plus typed Other text', async () => { + const wrapper = mountCard({ allowMultiple: true }); + const checkboxes = wrapper.findAll('[data-testid="ask-question-checkbox"]'); + await checkboxes[0].trigger('click'); + await wrapper.find('[data-testid="ask-question-other-input"]').setValue('Use Discord too'); + await wrapper.find('[data-testid="ask-question-submit"]').trigger('click'); + + const emitted = wrapper.emitted('submit') as unknown[][]; + expect(emitted[0][0]).toEqual({ values: ['a', 'Use Discord too'] }); + }); + + it('allows typed Other text as the only multiple-choice value', async () => { + const wrapper = mountCard({ allowMultiple: true }); + await wrapper.find('[data-testid="ask-question-other-input"]').setValue('Use Linear'); + await wrapper.find('[data-testid="ask-question-submit"]').trigger('click'); + + const emitted = wrapper.emitted('submit') as unknown[][]; + expect(emitted[0][0]).toEqual({ values: ['Use Linear'] }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue index 535b0743788..9a0de8aec4c 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentChatPanel.vue @@ -94,6 +94,16 @@ const missingFields = computed(() => { return fatalError.value.missing.map(humaniseMissingField).join(', '); }); +const hasOpenInteractiveQuestion = computed(() => + messages.value.some((message) => message.interactive && !message.interactive.resolvedAt), +); + +const chatPlaceholder = computed(() => + hasOpenInteractiveQuestion.value + ? locale.baseText('agents.chat.answerQuestionPlaceholder') + : locale.baseText('agents.chat.input.placeholder'), +); + function onOpenBuild() { dismissFatalError(); emit('open-build'); @@ -103,7 +113,9 @@ watch(isStreaming, (v) => emit('update:streaming', v)); async function onSubmit() { const text = inputText.value.trim(); - if (!text || isStreaming.value || isPreparingToSend.value) return; + if (!text || isStreaming.value || isPreparingToSend.value || hasOpenInteractiveQuestion.value) { + return; + } isPreparingToSend.value = true; try { @@ -135,6 +147,7 @@ async function onSubmit() { } function sendMessageFromOutside(message: string) { + if (hasOpenInteractiveQuestion.value) return; inputText.value = message; void onSubmit(); } @@ -244,10 +257,19 @@ onBeforeUnmount(() => { import { ref, computed, onBeforeUnmount } from 'vue'; -import { N8nButton, N8nCard, N8nCheckbox, N8nIcon, N8nText } from '@n8n/design-system'; +import { + N8nButton, + N8nCard, + N8nCheckbox, + N8nIcon, + N8nInput, + N8nInputLabel, + N8nText, +} from '@n8n/design-system'; +import { useI18n } from '@n8n/i18n'; import type { AskQuestionResume } from '@n8n/api-types'; interface Option { @@ -22,7 +31,9 @@ const emit = defineEmits<{ }>(); const SINGLE_CHOICE_SUBMIT_DELAY_MS = 250; +const i18n = useI18n(); const selected = ref([]); +const otherText = ref(''); let singleChoiceSubmitTimer: number | undefined; /** Labels of the persisted selected values, for the resolved state. */ @@ -33,6 +44,13 @@ const resolvedLabels = computed(() => { ); }); +const trimmedOtherText = computed(() => otherText.value.trim()); +const selectedValuesWithOther = computed(() => { + const values = [...selected.value]; + if (trimmedOtherText.value) values.push(trimmedOtherText.value); + return values; +}); + function selectSingle(value: string) { if (props.disabled) return; selected.value = [value]; @@ -61,8 +79,25 @@ function toggleMultiple(value: string, checked: boolean) { } function onSubmit() { - if (selected.value.length === 0 || props.disabled) return; - emit('submit', { values: [...selected.value] }); + const values = selectedValuesWithOther.value; + if (values.length === 0 || props.disabled) return; + emit('submit', { values }); +} + +function submitOther() { + if (!trimmedOtherText.value || props.disabled) return; + clearSingleChoiceSubmitTimer(); + emit('submit', { values: [trimmedOtherText.value] }); +} + +function onOtherKeydown(event: KeyboardEvent) { + if (event.key !== 'Enter' || event.shiftKey || event.isComposing) return; + event.preventDefault(); + if (props.allowMultiple) { + onSubmit(); + return; + } + submitOther(); } onBeforeUnmount(clearSingleChoiceSubmitTimer); @@ -129,14 +164,43 @@ onBeforeUnmount(clearSingleChoiceSubmitTimer); + +
+ + + {{ i18n.baseText('agents.chat.askQuestion.submit') }} + +
+
+
- Submit + {{ i18n.baseText('agents.chat.askQuestion.submit') }}
@@ -183,6 +247,24 @@ onBeforeUnmount(clearSingleChoiceSubmitTimer); gap: var(--spacing--3xs); } +.other { + display: flex; + flex-direction: column; + gap: var(--spacing--4xs); + padding-top: var(--spacing--2xs); +} + +.otherInputRow { + display: flex; + align-items: flex-start; + gap: var(--spacing--2xs); + + :global(.n8n-input) { + flex: 1; + min-width: 0; + } +} + .option { @include questionOptions.option-button-row; @include questionOptions.active-selected;