fix(core): Ask before building a picked onboarding suggestion (no-changelog) (backport to release-candidate/2.41.x) (#39956)

Co-authored-by: Raúl Gómez Morales <raul00gm@gmail.com>
This commit is contained in:
n8n-assistant[bot] 2026-09-30 08:02:26 +00:00 committed by GitHub
parent 063ba759a7
commit 3d79333ced
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 63 additions and 20 deletions

View File

@ -3,8 +3,9 @@ name: suggest-automations
description: >-
Offer the three most common automations for a user as a single-choice card,
from your own knowledge of their team and their apps, then build the one
they choose or offer more. The system preloads it on onboarding threads,
where the opening card already asked the user about themselves.
they choose once they confirm, or offer more. The system preloads it on
onboarding threads, where the opening card already asked the user about
themselves.
Use it on other threads when the user asks what they could automate or
wants ideas for a first workflow.
recommended_tools:
@ -25,14 +26,14 @@ block with the user's team and apps, one line per question. Use every line of
the block. The answers are final: use them as they come and do not ask these
questions again. Two openings:
- The block is followed by your message "Got it. Finally, tell me a little
about how you use <their apps>." and the user's reply. The reply describes how they
- The block is followed by your message "Last one: what do you usually do in
<their apps>?" and the user's reply. The reply describes how they
work, names a task in their own words, or names none. Start at step 1.
- The block is the latest message and one answer is free text the user typed
into the card. Read that text first. A tool name is one of their apps: ask
"Got it. Finally, tell me a little about how you use <their apps>." and end
the turn. A task: start at step 1 with it. Anything else, for example a wish to
stop, a request about something else, a refusal or an insult: call
"Last one: what do you usually do in <their apps>?" and end the turn. A
task: start at step 1 with it. Anything else, for example a wish to stop, a
request about something else, a refusal or an insult: call
`leave-onboarding`, then write a one-sentence reply that invites the user to
explore the app and to come back with a task.
@ -50,7 +51,7 @@ Slack or Google Sheets. Never run a command or read a file to find them.
complete one, suggestion one is that task in their words and the other two
are what people on their team set up next to it. Otherwise pick the three
that people on the team most often set up with the user's apps. The card
always comes before a build: a specific task gets confirmed with one click,
always comes before a build: a specific task gets picked and confirmed,
never built unasked. Decide in one step: do not run a command, read a file
or write the reasoning out.
2. Write short text and no list: the card carries the options. First
@ -93,17 +94,30 @@ Slack or Google Sheets. Never run a command or read a file to find them.
Example option: "Capture new leads\nMove new enquiries into your sales
process so nothing gets missed."
3. Read the answer.
- A suggestion: its first line, the title, names the automation to build.
- A suggestion: its first line, the title, names the automation. Go to
step 4.
- `Show me other ideas`: repeat step 2 with three automations not shown in
this thread yet, after one sentence such as "Here are three more."
- Free text that describes a task: that is the user's request; build it.
- Free text that describes a task: that is the user's request. Give it a
title as in step 2 and go to step 4.
- Free text that asks for a change, for example another app or a topic:
repeat step 2 with three automations that fit it.
- Free text that names no task and no change, for example "skip", "no" or
"later", or `answered: false`: do not build. On an onboarding thread call
`leave-onboarding`, then write a one-sentence reply, for example "No
problem. Explore the app and tell me when you want to automate something."
4. Write exactly one line before the first tool call, `Building <title> now.`,
4. Ask before you build. Write no text, and make ONE `ask-user` call with
`questions` only: a `single` question "Nice! Should I build this now?",
`required: true`, with the options `Yes, build it` and `Not now`. Leave
`introMessage` and `freeTextLabel` out: the card's own "Something else"
row lets the user ask for another automation. Read the answer:
- `Yes, build it`: go to step 5.
- `Not now`: do not build, and stay in the flow. Repeat step 2 with the
same three automations, after one sentence such as "No problem. Pick
another one, or ask for other ideas."
- `answered: false`: do not build. Reply as for a skip in step 3.
- Free text: read it as in step 3.
5. Write exactly one line before the first tool call, `Building <title> now.`,
and no other text until the `build-workflow` result. Load `workflow-builder`
and build the automation with the user's apps the normal way, then follow
`postBuildFlow.instructions` from the result. Ask about an app only for a
@ -112,8 +126,9 @@ Slack or Google Sheets. Never run a command or read a file to find them.
## Rules
- One `ask-user` call per turn. Keep every message to four sentences or fewer.
- A build needs a picked suggestion or a task in the user's words. Never
start one after a skip, a dismissal, or free text that names nothing.
- A build needs `Yes, build it` from the user on the step 4 card, for a
picked suggestion and for a task in the user's words alike. Never start one
after a skip, a dismissal, or free text that names nothing.
- No apps given: suggest what people on the team most often automate, and
name no app.
- Talk about the automation, never about the mechanics. Do not say

View File

@ -149,7 +149,7 @@ describe('InstanceAiOnboardingService answerCard', () => {
expect(memoryService.seedOpeningMessages).toHaveBeenCalledWith(
THREAD_ID,
user.id,
'Got it. Finally, tell me a little about how you use Gmail and Slack.',
'Last one: what do you usually do in Gmail and Slack?',
expect.stringContaining('<onboarding-answer>'),
);
});

View File

@ -35,7 +35,7 @@ export const ONBOARDING_OPENING: OnboardingOpening = {
'Think of me as your n8n expert.',
'Two final questions, so I can suggest automations for you.',
].join('\n\n'),
followUp: 'Got it. Finally, tell me a little about how you use {{apps}}.',
followUp: 'Last one: what do you usually do in {{apps}}?',
questions: [
// The n8n Cloud signup form's team labels, so the answers stay comparable with the Cloud
// metadata (`what_team_are_you_on`). The card hides options that start with "other"; its

View File

@ -39,7 +39,8 @@ const renderComponent = createThreadComponentRenderer(InstanceAiMessageComponent
global: {
stubs: {
AgentActivityTree: {
template: '<div data-test-id="agent-activity-tree" />',
template:
'<div data-test-id="agent-activity-tree" :data-entries="agentNode.timeline.length" />',
props: ['agentNode', 'isRoot'],
},
},
@ -413,10 +414,11 @@ describe('InstanceAiMessage', () => {
const bubble = getByTestId('instance-ai-answered-questions');
expect(bubble).toHaveTextContent('HubSpot');
expect(copy.compareDocumentPosition(bubble) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(getByTestId('agent-activity-tree')).toHaveAttribute('data-entries', '1');
});
it('should leave answers in the timeline when text follows them', () => {
const { queryByTestId } = renderComponent({
const { getByTestId, queryByTestId } = renderComponent({
props: {
message: makeMessage({
content: 'Hi',
@ -429,6 +431,28 @@ describe('InstanceAiMessage', () => {
});
expect(queryByTestId('instance-ai-answered-questions')).not.toBeInTheDocument();
expect(getByTestId('agent-activity-tree')).toHaveAttribute('data-entries', '3');
});
it.each([
{ name: 'a pending card', status: 'completed', isLoading: true },
{ name: 'an answer while the run is active', status: 'active', isLoading: false },
] as const)('should leave $name in the timeline', ({ status, isLoading }) => {
const { getByTestId, queryByTestId } = renderComponent({
props: {
message: makeMessage({
content: 'Hi',
agentTree: makeAgentTree({
status,
toolCalls: [{ ...answeredCall, isLoading }],
timeline: [greeting, answer],
}),
}),
},
});
expect(queryByTestId('instance-ai-answered-questions')).not.toBeInTheDocument();
expect(getByTestId('agent-activity-tree')).toHaveAttribute('data-entries', '2');
});
});
});

View File

@ -171,6 +171,8 @@ const hasAssistantResponse = computed(() => displayedMessages.some((m) => m.role
/** The third line has risen at ~1.7 s, the card lands at ~2.7 s. */
const GREETING_LINES_MS = 1760;
const GREETING_THINKING_MS = 940;
/** Longer than the greeting's beat, so the last question does not land the moment the card closes. */
const FOLLOW_UP_THINKING_MS = 1800;
const greetingPhase = ref<'lines' | 'thinking' | null>(null);
let greetingShown = false;
let greetingTimer: ReturnType<typeof setTimeout> | null = null;
@ -208,7 +210,7 @@ watch(
followUpTimer = setTimeout(() => {
followUpHeld.value = false;
followUpTimer = null;
}, GREETING_THINKING_MS);
}, FOLLOW_UP_THINKING_MS);
},
);
onUnmounted(() => {

View File

@ -119,9 +119,11 @@ const attachments = computed(() =>
*/
const trailingAnswer = computed(() => {
const tree = props.message.agentTree;
const last = tree?.timeline.at(-1);
// A live run appends after the answer. Only a settled message ends on it.
if (!tree || tree.status === 'active') return undefined;
const last = tree.timeline.at(-1);
if (last?.type !== 'tool-call') return undefined;
const toolCall = tree?.toolCalls.find((tc) => tc.toolCallId === last.toolCallId);
const toolCall = tree.toolCalls.find((tc) => tc.toolCallId === last.toolCallId);
return toolCall?.confirmation?.inputType === 'questions' && !toolCall.isLoading
? toolCall
: undefined;