fix(editor): Disable chat during interactive agent choices (#30111)

This commit is contained in:
bjorger 2026-05-08 17:14:38 +02:00 committed by GitHub
parent 523fd85e45
commit 8171cf0b32
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 313 additions and 19 deletions

View File

@ -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<typeof askQuestionOptionSchema>;

View File

@ -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.

View File

@ -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();

View File

@ -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)

View File

@ -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)

View File

@ -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}",

View File

@ -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<ChatMessage[]>([]);
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);
},
);
});

View File

@ -26,6 +26,15 @@ function mountCard(props = {}) {
template:
'<button type="button" data-testid="n8n-checkbox" :aria-checked="String(modelValue)" :disabled="disabled" @click="$emit(\'update:modelValue\', !modelValue)"></button>',
},
N8nInput: {
props: ['modelValue', 'disabled', 'placeholder'],
template:
'<input :value="modelValue" :disabled="disabled" :placeholder="placeholder" v-bind="$attrs" @input="$emit(\'update:modelValue\', $event.target.value)" @keydown="$emit(\'keydown\', $event)" />',
},
N8nInputLabel: {
props: ['label'],
template: '<label><span>{{ label }}</span><slot /></label>',
},
N8nText: { template: '<p><slot/></p>' },
},
},
@ -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'] });
});
});

View File

@ -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(() => {
<slot name="above-input" />
<ChatInputBase
v-model="inputText"
placeholder="Type a message..."
:placeholder="chatPlaceholder"
:is-streaming="messagingState === 'receiving'"
:can-submit="!isStreaming && !isPreparingToSend && inputText.trim().length > 0"
:disabled="isPreparingToSend || (isStreaming && messagingState !== 'receiving')"
:can-submit="
!hasOpenInteractiveQuestion &&
!isStreaming &&
!isPreparingToSend &&
inputText.trim().length > 0
"
:disabled="
hasOpenInteractiveQuestion ||
isPreparingToSend ||
(isStreaming && messagingState !== 'receiving')
"
data-testid="chat-input"
@submit="onSubmit"
@stop="stopGenerating"

View File

@ -1,6 +1,15 @@
<script setup lang="ts">
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<string[]>([]);
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);
</template>
</div>
<N8nInputLabel
input-name="ask-question-other-input"
:label="i18n.baseText('agents.chat.askQuestion.otherLabel')"
:bold="false"
size="small"
:class="$style.other"
>
<div :class="$style.otherInputRow">
<N8nInput
id="ask-question-other-input"
v-model="otherText"
size="small"
:disabled="disabled"
:placeholder="i18n.baseText('agents.chat.askQuestion.otherPlaceholder')"
data-testid="ask-question-other-input"
@keydown="onOtherKeydown"
/>
<N8nButton
v-if="!allowMultiple"
:disabled="!trimmedOtherText || disabled"
size="small"
data-testid="ask-question-other-submit"
@click="submitOther"
>
{{ i18n.baseText('agents.chat.askQuestion.submit') }}
</N8nButton>
</div>
</N8nInputLabel>
<div v-if="allowMultiple" :class="$style.actions">
<N8nButton
:disabled="selected.length === 0 || disabled"
:disabled="selectedValuesWithOther.length === 0 || disabled"
size="medium"
data-testid="ask-question-submit"
@click="onSubmit"
>
Submit
{{ i18n.baseText('agents.chat.askQuestion.submit') }}
</N8nButton>
</div>
</template>
@ -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;