feat(editor): Track Instance AI pre-fills with telemetry (no-changelog) (#38691)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mutasem Aldmour 2026-09-16 10:14:48 +00:00 committed by GitHub
parent 1d0c65856b
commit e009b9aaa0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
44 changed files with 1447 additions and 291 deletions

View File

@ -488,6 +488,8 @@ export {
deriveInstanceAiSetupState,
INSTANCE_AI_THREAD_SOURCES,
INSTANCE_AI_THREAD_SOURCE_FALLBACK,
INSTANCE_AI_PREFILL_TYPES,
INSTANCE_AI_PREFILL_TYPE_FALLBACK,
instanceAiBuildModeSchema,
instanceAiPromptConfigurationSchema,
INSTANCE_AI_RUN_LIMIT_REASONS,
@ -496,6 +498,8 @@ export {
export type {
InstanceAiBuildMode,
InstanceAiPromptConfiguration,
InstanceAiPrefillType,
InstanceAiPrefillTypeReported,
InstanceAiThreadSource,
InstanceAiThreadSourcePersisted,
InstanceAiThreadOrigin,

View File

@ -1490,6 +1490,64 @@ export type InstanceAiThreadSourcePersisted =
| InstanceAiThreadSource
| typeof INSTANCE_AI_THREAD_SOURCE_FALLBACK;
/**
* Pre-fill taxonomy for Instance AI messages. A pre-fill is message text n8n
* wrote, not text the user typed: the opener a failed execution, a credential
* modal, a template card or a suggestion chip puts in the composer. Analytics
* used to recover the type by string-matching the message body, which broke
* silently every time a catalog was reworded, so the client states it instead.
*
* Every new pre-fill surface must register a value here. The editor's send
* boundary requires an authorship and its catalogs must declare a type, so a
* surface that skips this fails typecheck rather than reporting untagged
* messages. Reported on `User sent builder message` as `prefill_type`.
*
* - `handoff_execution_error` — "Ask AI" on a failed execution or node error
* - `handoff_credential_setup` — credential help from the editor, credentials
* list, or the workflow artifact in a live thread
* - `handoff_fix_with_ai` — the in-thread fix-with-AI offer after a failed run
* - `handoff_setup_panel_execute` — the setup panel's Execute button; lands
* mid-thread rather than as a first message
* - `handoff_agent_change_request` — agent builder hand-off: a fix request or a
* change request, dropped into the composer
* - `template_adjustment` — "start from this template and help me adapt it",
* from the in-app template preview or an n8n.io deep link
* - `template_example` — a featured example card on the empty state
* - `suggestion_catalog` — a suggestion chip from any catalog; the entry id
* travels separately and is already catalog-prefixed
* - `v1_opener` — the original empty-state openers ("I want to build a new
* workflow…")
* - `workflow_attachment_opener` — a workflow opened in the assistant, which
* sends an empty message and lets the editor context greet
* - `contextual_followup` — the follow-up the composer offers as a placeholder
* after a build, accepted with Tab; lands mid-thread
*/
export const INSTANCE_AI_PREFILL_TYPES = [
'handoff_execution_error',
'handoff_credential_setup',
'handoff_fix_with_ai',
'handoff_setup_panel_execute',
'handoff_agent_change_request',
'template_adjustment',
'template_example',
'suggestion_catalog',
'v1_opener',
'workflow_attachment_opener',
'contextual_followup',
] as const;
export type InstanceAiPrefillType = (typeof INSTANCE_AI_PREFILL_TYPES)[number];
/**
* Read-path fallback, mirroring `INSTANCE_AI_THREAD_SOURCE_FALLBACK`. Only
* reachable for a pre-fill a previous deploy stashed in the browser, which
* carries no type. Deliberately outside `INSTANCE_AI_PREFILL_TYPES` so a new
* surface cannot declare it.
*/
export const INSTANCE_AI_PREFILL_TYPE_FALLBACK = 'unknown';
export type InstanceAiPrefillTypeReported =
| InstanceAiPrefillType
| typeof INSTANCE_AI_PREFILL_TYPE_FALLBACK;
export const INSTANCE_AI_THREAD_ORIGINS = ['internal', 'external'] as const;
export type InstanceAiThreadOrigin = (typeof INSTANCE_AI_THREAD_ORIGINS)[number];

View File

@ -23,6 +23,7 @@
"dist/**/*"
],
"dependencies": {
"@n8n/api-types": "workspace:*",
"@n8n/utils": "workspace:*",
"zod": "catalog:"
},

View File

@ -1,3 +1,9 @@
import {
INSTANCE_AI_PREFILL_TYPES,
INSTANCE_AI_PREFILL_TYPE_FALLBACK,
INSTANCE_AI_THREAD_SOURCES,
INSTANCE_AI_THREAD_SOURCE_FALLBACK,
} from '@n8n/api-types';
import { z } from 'zod/v4';
import { defineTelemetryEvents } from '../define';
@ -37,6 +43,14 @@ const freeNudgeTreatmentVariant = z.enum(['variant-1', 'variant-2']);
// Experiment cleanup: remove with openWorkflowInAssistant.
const openWorkflowInAssistantVariant = z.enum(['control', 'variant']);
// Both taxonomies are owned by `@n8n/api-types`, beside the request schemas that
// enforce them; the fallbacks are read-path only, so no caller can declare them.
const threadActionSource = z.enum([
...INSTANCE_AI_THREAD_SOURCES,
INSTANCE_AI_THREAD_SOURCE_FALLBACK,
]);
const prefillType = z.enum([...INSTANCE_AI_PREFILL_TYPES, INSTANCE_AI_PREFILL_TYPE_FALLBACK]);
export const INSTANCE_AI_TELEMETRY = defineTelemetryEvents({
USER_CLICKED_AI_CREDIT_BALANCE: {
name: 'User clicked AI credit balance',
@ -336,6 +350,38 @@ export const INSTANCE_AI_TELEMETRY = defineTelemetryEvents({
node_count: z.number().describe('Total nodes attached across the sent message'),
}),
},
USER_SENT_BUILDER_MESSAGE: {
name: 'User sent builder message',
description:
'The user sent a message to the n8n Assistant. Fires once per message on the optimistic send, before the request is admitted, so a refused send still counts as an attempt. Carries who wrote the text: a pre-fill is an opener n8n composed (a failed execution, a credential modal, a template card, a suggestion chip) that the user accepted or edited, so pre-fill share must be read from prefill_type rather than matched against the message body.',
properties: z.object({
thread_id: z.string(),
instance_id: z.string(),
is_first_message: z
.boolean()
.describe('Whether this is the first user message in the thread'),
action_source: threadActionSource.describe(
"The thread's entry point, read back from thread metadata. 'unknown' covers threads created before source was required.",
),
prefill_type: prefillType
.nullable()
.describe(
"Which pre-fill surface composed the text. Null when the user typed it. 'unknown' is a read-path fallback for a pre-fill a previous deploy stashed in the browser, so a non-trivial share of it is a bug, not a category.",
),
prefill_id: z
.string()
.nullable()
.describe(
'Catalog entry id for pre-fill types that have sub-items, e.g. a suggestion id. Null otherwise.',
),
prompt_modified: z
.boolean()
.nullable()
.describe(
'Whether the user edited the pre-filled text before sending. Always false for pre-fills that send without being shown. Null when the user typed the message.',
),
}),
},
BUILDER_LISTED_WORKFLOWS: {
name: 'Builder listed workflows',
description:

View File

@ -107,6 +107,7 @@ describe('InstanceAiPersonalizedPromptSuggestions', () => {
prompt: 'Build a follow-up workflow',
suggestionId: 'initial-2',
suggestionKind: 'prompt',
prefillType: 'suggestion_catalog',
position: 2,
telemetryPayload: undefined,
},
@ -187,6 +188,7 @@ describe('InstanceAiPersonalizedPromptSuggestions', () => {
prompt: 'Build a WhatsApp support workflow',
suggestionId: 'fallback-1',
suggestionKind: 'prompt',
prefillType: 'suggestion_catalog',
position: 1,
telemetryPayload: { suggestion_source: 'v2_top_used_fallback' },
},

View File

@ -8,6 +8,7 @@ import type {
PersonalizedPromptFormat,
PersonalizedPromptSuggestionSource,
} from '../types';
import type { InstanceAiPrefillDeclaration } from '@/features/ai/instanceAi/prefills';
const SUGGESTION_ENTER_STAGGER_MS = 45;
@ -19,7 +20,7 @@ const props = defineProps<{
showSeeMore: boolean;
}>();
type InsertSuggestionPayload = {
type InsertSuggestionPayload = InstanceAiPrefillDeclaration & {
prompt: string;
suggestionId: string;
suggestionKind: 'prompt';
@ -91,6 +92,7 @@ function handleSuggestionClick(suggestion: PersonalizedPromptDisplaySuggestion)
suggestionId: suggestion.id,
suggestionKind: 'prompt',
position,
prefillType: 'suggestion_catalog',
telemetryPayload: isShowingFallback.value
? {
suggestion_source: 'v2_top_used_fallback',

View File

@ -231,6 +231,7 @@ describe('InstanceAiPromptSuggestionsV2', () => {
promptKey: 'instanceAi.emptyState.quickExamples.answerSupportRequests.prompt',
suggestionId: 'answer-support-requests',
suggestionKind: 'prompt',
prefillType: 'suggestion_catalog',
position: 6,
},
],

View File

@ -2,6 +2,7 @@
import { N8nIcon, N8nTooltip, type IconName } from '@n8n/design-system';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { computed, onUnmounted, ref } from 'vue';
import type { InstanceAiPrefillDeclaration } from '@/features/ai/instanceAi/prefills';
const VISIBLE_SUGGESTION_COUNT = 4;
const PREVIEW_HOVER_DELAY_MS = 300;
@ -20,7 +21,7 @@ const props = defineProps<{
disabled: boolean;
}>();
interface InsertSuggestionPayload {
interface InsertSuggestionPayload extends InstanceAiPrefillDeclaration {
promptKey: BaseTextKey;
suggestionId: string;
suggestionKind: 'prompt';
@ -136,6 +137,7 @@ function handleSuggestionClick(suggestion: InstanceAiPromptSuggestionV2) {
suggestionId: suggestion.id,
suggestionKind: 'prompt',
position: getSuggestionPosition(suggestion.id),
prefillType: 'suggestion_catalog',
});
}

View File

@ -104,6 +104,7 @@ describe('SuggestionList', () => {
promptKey: example.promptKey,
suggestionId: example.id,
suggestionKind: 'quick_example',
prefillType: 'suggestion_catalog',
position: 2,
},
],
@ -122,6 +123,7 @@ describe('SuggestionList', () => {
promptKey: example.promptKey,
suggestionId: example.id,
suggestionKind: 'quick_example',
prefillType: 'suggestion_catalog',
position: 2,
},
],

View File

@ -48,6 +48,7 @@ function buildPayload(
suggestionId: example.id,
suggestionKind: 'quick_example',
position: index + 1,
prefillType: 'suggestion_catalog',
};
}

View File

@ -1,4 +1,5 @@
import type { BaseTextKey } from '@n8n/i18n';
import type { InstanceAiPrefillDeclaration } from '@/features/ai/instanceAi/prefills';
export interface SplitEmptyStateExample {
id: string;
@ -7,7 +8,7 @@ export interface SplitEmptyStateExample {
workflowFile: string;
}
export interface SplitEmptyStateSuggestionSubmitPayload {
export interface SplitEmptyStateSuggestionSubmitPayload extends InstanceAiPrefillDeclaration {
promptKey: BaseTextKey;
suggestionId: string;
suggestionKind: 'quick_example';

View File

@ -42,6 +42,7 @@ describe('WorkflowPreviewSuggestions', () => {
promptKey: suggestion.promptKey,
suggestionId: suggestion.id,
suggestionKind: 'prompt',
prefillType: 'suggestion_catalog',
position: 2,
},
],

View File

@ -5,6 +5,7 @@ import { onUnmounted, ref } from 'vue';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { useTemplatesStore } from '@/features/workflows/templates/templates.store';
import { type WorkflowPreviewSuggestion } from '../suggestions';
import type { InstanceAiPrefillDeclaration } from '@/features/ai/instanceAi/prefills';
const PREVIEW_HOVER_DELAY_MS = 30;
@ -13,7 +14,7 @@ const props = defineProps<{
disabled: boolean;
}>();
interface InsertSuggestionPayload {
interface InsertSuggestionPayload extends InstanceAiPrefillDeclaration {
promptKey: BaseTextKey;
suggestionId: string;
suggestionKind: 'prompt';
@ -95,6 +96,7 @@ function handleSuggestionClick(suggestion: WorkflowPreviewSuggestion) {
suggestionId: suggestion.id,
suggestionKind: 'prompt',
position,
prefillType: 'suggestion_catalog',
});
}

View File

@ -83,6 +83,7 @@ describe('launchWorkflowThread', () => {
{
message: '',
attachments: [{ type: 'workflow', id: 'wf1', name: 'Gmail fetch' }],
authorship: { kind: 'prefill', prefillType: 'workflow_attachment_opener' },
},
{
source: 'workflow_list_auto',

View File

@ -52,7 +52,11 @@ export async function launchWorkflowThread(
: 'workflow_list_auto';
const threadId = await provisionLaunchedThread(
projectId,
{ message: '', attachments: [{ type: 'workflow', id: workflowId, name }] },
{
message: '',
attachments: [{ type: 'workflow', id: workflowId, name }],
authorship: { kind: 'prefill', prefillType: 'workflow_attachment_opener' },
},
{ source, origin: 'internal', sourceContext: { workflowId } },
);
if (!threadId) return editorFallback;

View File

@ -965,13 +965,17 @@ describe('AgentBuilderView — preview routing', { timeout: 60_000 }, () => {
...(event
? {
executionId: event.executionId,
initialDraft: expect.any(String),
initialDraft: {
text: expect.any(String),
prefillType: 'handoff_agent_change_request',
},
}
: {}),
});
if (event) {
const initialDraft = sendPreviewSessionToInstanceAiMock.mock.calls[0]?.[0]?.initialDraft;
const initialDraft =
sendPreviewSessionToInstanceAiMock.mock.calls[0]?.[0]?.initialDraft?.text;
expect(initialDraft).toContain(
'Review these failed tool calls, identify the root cause, fix the agent, and verify the change.',
);
@ -1038,9 +1042,12 @@ describe('AgentBuilderView — preview routing', { timeout: 60_000 }, () => {
threadId: 'thread-1',
sessionTitle: 'Failed order lookup',
executionId: 'exec-turn-1',
initialDraft: expect.stringContaining(
'Review these failed tool calls, identify the root cause, fix the agent, and verify the change.',
),
initialDraft: {
text: expect.stringContaining(
'Review these failed tool calls, identify the root cause, fix the agent, and verify the change.',
),
prefillType: 'handoff_agent_change_request',
},
}),
],
]);
@ -1122,7 +1129,10 @@ describe('AgentBuilderView — preview routing', { timeout: 60_000 }, () => {
expect(wrapper.emitted('assistant-handoff')).toEqual([
[
expect.objectContaining({
initialDraft: expect.stringContaining('"sessionNumber": 42'),
initialDraft: {
text: expect.stringContaining('"sessionNumber": 42'),
prefillType: 'handoff_agent_change_request',
},
}),
],
]);

View File

@ -264,21 +264,29 @@ async function onSendPreviewToAssistant(event?: AgentSendToAssistantEvent) {
: 'failures' in event
? {
executionId: event.executionId,
initialDraft: buildAgentFixWithAssistantPrompt(
{
projectId: projectId.value,
agentId: agentId.value,
agentName: agentName.value || undefined,
threadId,
sessionTitle,
...(sessionNumber !== undefined ? { sessionNumber } : {}),
executionId: event.executionId,
failures: event.failures,
},
locale,
),
initialDraft: {
text: buildAgentFixWithAssistantPrompt(
{
projectId: projectId.value,
agentId: agentId.value,
agentName: agentName.value || undefined,
threadId,
sessionTitle,
...(sessionNumber !== undefined ? { sessionNumber } : {}),
executionId: event.executionId,
failures: event.failures,
},
locale,
),
prefillType: 'handoff_agent_change_request',
},
}
: { initialDraft: buildAgentChangeRequestPrompt(event.changeRequest, locale) }),
: {
initialDraft: {
text: buildAgentChangeRequestPrompt(event.changeRequest, locale),
prefillType: 'handoff_agent_change_request',
},
}),
};
if (isArtifactMode.value) {

View File

@ -24,6 +24,7 @@ import { INSTANCE_AI_TEMPLATE_EXAMPLES_EXPOSURE_EVENT } from '@/experiments/inst
import { useSettingsStore } from '@n8n/stores/settings.store';
import { useCloudPlanStore } from '@n8n/stores/cloudPlan.store';
import { useInstanceAiStore } from './instanceAi.store';
import type { InstanceAiMessageAuthorship, InstanceAiPrefillDeclaration } from './prefills';
import { useInstanceAiSettingsStore } from './instanceAiSettings.store';
import {
INSTANCE_AI_THREAD_VIEW,
@ -541,10 +542,17 @@ function handleTemplateHoverEnd() {
const inputPulsing = ref(false);
const selectedTemplatePrompt = ref<string | null>(null);
type ShelfSuggestionPayload = InstanceAiPrefillDeclaration & {
promptKey: BaseTextKey;
suggestionId: string;
suggestionKind: 'prompt' | 'quick_example';
position: number;
};
function handleTemplateSelectPrompt(prompt: string) {
templatePreviewPrompt.value = null;
if (chatInputRef.value) {
chatInputRef.value.setText(prompt);
chatInputRef.value.setPrefill({ text: prompt, prefillType: 'template_example' });
chatInputRef.value.focus();
}
selectedTemplatePrompt.value = prompt;
@ -597,27 +605,27 @@ onMounted(() => {
onUnmounted(clearPersonalizedPromptMetadataTimeout);
function restoreDraftAfterFailedSubmit(message: string, restoreDraft?: () => boolean) {
function restoreDraftAfterFailedSubmit(restoreDraft: () => boolean) {
void nextTick(() => {
// Restore text without replacing new text or attachments.
if (!restoreDraft?.()) {
chatInputRef.value?.setTextIfEmpty(message);
}
// Puts the text, the attachments and the pre-fill provenance back, and
// declines if the user has already typed something newer.
restoreDraft();
chatInputRef.value?.focus();
});
}
async function handleSubmit(
message: string,
attachments?: InstanceAiAttachment[],
restoreDraft?: () => boolean,
attachments: InstanceAiAttachment[] | undefined,
restoreDraft: () => boolean,
authorship: InstanceAiMessageAuthorship,
) {
if (!settingsStore.isWorkflowBuilderAvailable) {
return;
}
if (!selectedProject.value) {
restoreDraftAfterFailedSubmit(message, restoreDraft);
restoreDraftAfterFailedSubmit(restoreDraft);
toast.showError(new Error('Please select a project before starting a thread.'), 'Send failed');
return;
}
@ -642,7 +650,7 @@ async function handleSubmit(
});
} catch {
isStartingThread.value = false;
restoreDraftAfterFailedSubmit(message, restoreDraft);
restoreDraftAfterFailedSubmit(restoreDraft);
toast.showError(new Error('Failed to start a new thread. Try again.'), 'Send failed');
return;
}
@ -653,10 +661,14 @@ async function handleSubmit(
// not an option: it reads its composer draft from localStorage once, synchronously, on
// mount, which always precedes this response. `sendMessage` has already surfaced the
// reason, so restore what was typed and stay put.
const sent = await thread.sendMessage(finalMessage, attachments, rootStore.pushRef);
const sent = await thread.sendMessage(finalMessage, {
authorship,
attachments,
pushRef: rootStore.pushRef,
});
if (!sent) {
isStartingThread.value = false;
restoreDraftAfterFailedSubmit(message, restoreDraft);
restoreDraftAfterFailedSubmit(restoreDraft);
// `syncThread` already persisted the thread and `sendMessage` already opened its SSE,
// so without this every refusal would strand a blank thread in the sidebar and leave
// an EventSource open behind it (deleting disposes the runtime, which closes it).
@ -697,21 +709,11 @@ async function handleSubmit(
}
}
function handleShelfSuggestionSubmit(payload: {
promptKey: BaseTextKey;
suggestionId: string;
suggestionKind: 'prompt' | 'quick_example';
position: number;
}) {
function handleShelfSuggestionSubmit(payload: ShelfSuggestionPayload) {
void chatInputRef.value?.submitSuggestion(payload);
}
function handleShelfSuggestionInsert(payload: {
promptKey: BaseTextKey;
suggestionId: string;
suggestionKind: 'prompt' | 'quick_example';
position: number;
}) {
function handleShelfSuggestionInsert(payload: ShelfSuggestionPayload) {
splitPreviewPromptKey.value = null;
void chatInputRef.value?.insertSuggestion(payload);
}

View File

@ -78,7 +78,9 @@ import {
stashPendingComposerDraft,
stashPendingFirstMessage,
stashPendingHandoffContext,
type PendingComposerDraft,
} from './composables/useInstanceAiHandoff';
import type { InstanceAiMessageAuthorship } from './prefills';
import type { AgentPreviewHandoffParams } from './composables/useInstanceAiAgentPreviewHandoff';
import { useTransitionGate } from './useTransitionGate';
import {
@ -141,7 +143,7 @@ const { isCollapsed: isMainSidebarCollapsed, sidebarWidth: mainSidebarWidth } =
const telemetry = useTelemetry();
const toast = useToast();
const pendingComposerContext = ref<InstanceAiHandoffContext | null>(null);
const pendingComposerDraft = ref<string | null>(null);
const pendingComposerDraft = ref<PendingComposerDraft | null>(null);
const generatedComposerDraft = ref<string | null>(null);
const pendingAgentAttachment = ref<InstanceAiAgentAttachment | null>(null);
const currentAgentAttachment = computed<InstanceAiAgentAttachment | null>(() => {
@ -762,8 +764,8 @@ watch(
[chatInputRef, pendingComposerDraft, () => thread.pendingPlanReview],
([input, draft, planReview]) => {
if (!input || !draft || planReview) return;
input.setText(draft);
generatedComposerDraft.value = draft;
input.setPrefill({ text: draft.text, prefillType: draft.prefillType });
generatedComposerDraft.value = draft.text;
pendingComposerDraft.value = null;
void nextTick(focusChatInputIfFocusIsIdle);
},
@ -862,7 +864,12 @@ function reconnectThreadAfterHydration(): void {
const pending = consumePendingFirstMessage(props.threadId);
if (pending) {
void thread
.sendMessage(pending.message, pending.attachments, rootStore.pushRef, pending.context)
.sendMessage(pending.message, {
authorship: pending.authorship,
attachments: pending.attachments,
pushRef: rootStore.pushRef,
handoffContext: pending.context,
})
.then((sent) => {
if (sent) return;
// Consuming already removed it, so a refused send (e.g. a concurrency cap)
@ -943,10 +950,14 @@ const workflowPreviewRef =
// --- Message handlers ---
/** Put a failed submission back in the composer without clobbering newer typing. */
function restoreFailedSubmission(message: string, restoreDraft?: () => boolean) {
if (restoreDraft?.()) return;
const input = chatInputRef.value;
if (input && !input.isDirty()) input.setText(message);
/**
* Hand a refused submission back to the composer. Its own restore returns the
* text, the attachments and the pre-fill provenance together -- so a retry stays
* attributed to the surface that wrote the draft -- and declines when the user
* has already typed something newer.
*/
function restoreFailedSubmission(restoreDraft: () => boolean) {
restoreDraft();
}
/**
@ -961,8 +972,9 @@ const isPlanChangeInFlight = computed(() => {
function handleSubmit(
message: string,
attachments?: InstanceAiAttachment[],
restoreDraft?: () => boolean,
attachments: InstanceAiAttachment[] | undefined,
restoreDraft: () => boolean,
authorship: InstanceAiMessageAuthorship,
) {
if (!settingsStore.isWorkflowBuilderAvailable) {
return;
@ -977,7 +989,7 @@ function handleSubmit(
if (planReview) {
void thread.requestPlanChanges(planReview.requestId, message).then((sent) => {
if (!sent) {
restoreFailedSubmission(message, restoreDraft);
restoreFailedSubmission(restoreDraft);
return;
}
// Only an accepted request revises the plan. Tracking up front would
@ -1014,10 +1026,15 @@ function handleSubmit(
const nodeCount = countAttachedNodes(attachments);
void thread
.sendMessage(message, submittedAttachments, rootStore.pushRef, handoffContext)
.sendMessage(message, {
authorship,
attachments: submittedAttachments,
pushRef: rootStore.pushRef,
handoffContext,
})
.then((sent) => {
if (!sent) {
restoreFailedSubmission(message, restoreDraft);
restoreFailedSubmission(restoreDraft);
return;
}
// Track message-with-nodes only after a successful send, so failed
@ -1060,8 +1077,10 @@ function handleFixWithAiFromOffer() {
userScrolledUp.value = false;
void thread.sendMessage(
buildFixWithAiPrompt({ workflowName: offer.workflowName, errors: offer.errors }),
undefined,
rootStore.pushRef,
{
authorship: { kind: 'prefill', prefillType: 'handoff_fix_with_ai' },
pushRef: rootStore.pushRef,
},
);
}
@ -1159,7 +1178,7 @@ async function persistTestAgentOfferDismissal(agentId: string) {
}
function clearPendingComposerHandoff() {
const draft = generatedComposerDraft.value ?? pendingComposerDraft.value;
const draft = generatedComposerDraft.value ?? pendingComposerDraft.value?.text;
if (draft) chatInputRef.value?.clearTextIfMatches(draft);
pendingComposerDraft.value = null;
generatedComposerDraft.value = null;

View File

@ -360,6 +360,7 @@ describe('InstanceAiCredentialSetup', () => {
expect(instanceAiHandoffMock.startThread).toHaveBeenCalledWith(
'project-1',
expect.stringContaining('fal.ai API Key'),
{ kind: 'prefill', prefillType: 'handoff_credential_setup' },
{ source: 'credential_edit', origin: 'internal' },
undefined,
undefined,

View File

@ -1,5 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { defineComponent, h, nextTick, reactive, ref } from 'vue';
import { USER_TYPED_MESSAGE, type InstanceAiPrefillType } from '../prefills';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { createTestingPinia } from '@pinia/testing';
import { setActivePinia } from 'pinia';
@ -162,6 +163,7 @@ vi.mock('@/experiments/instanceAiSplitEmptyState', async () => {
suggestionId: 'score-my-leads',
suggestionKind: 'quick_example',
position: 1,
prefillType: 'suggestion_catalog',
}),
},
'submit',
@ -323,9 +325,38 @@ const InstanceAiInputStub = defineComponent({
setup(props, { emit, expose, slots }) {
const i18n = useI18n();
const currentText = ref('');
// Mirrors the real composer: whatever pre-filled the box is reported on submit.
const activePrefill = ref<{
text: string;
prefillType: InstanceAiPrefillType;
prefillId?: string;
} | null>(null);
const submit = (message: string) => {
emit('submit', message);
const prefill = activePrefill.value;
// Mirrors the real composer: the restore callback is always provided, and
// it puts the pre-fill back with the text so a retry stays attributed.
const restoreDraft = () => {
if (currentText.value.trim()) return false;
currentText.value = message;
activePrefill.value = prefill;
return true;
};
emit(
'submit',
message,
undefined,
restoreDraft,
prefill
? {
kind: 'prefill',
prefillType: prefill.prefillType,
...(prefill.prefillId ? { prefillId: prefill.prefillId } : {}),
promptModified: message !== prefill.text.trim(),
}
: USER_TYPED_MESSAGE,
);
currentText.value = '';
activePrefill.value = null;
};
expose({
focus: vi.fn(),
@ -335,9 +366,25 @@ const InstanceAiInputStub = defineComponent({
setText: (text: string) => {
currentText.value = text;
},
setPrefill: (prefill: { text: string; prefillType: InstanceAiPrefillType }) => {
currentText.value = prefill.text;
activePrefill.value = { ...prefill };
},
// Mirror the real submitSuggestion: resolve the prompt + emit submit.
submitSuggestion: (payload: { promptKey: BaseTextKey }) =>
submit(i18n.baseText(payload.promptKey)),
submitSuggestion: (payload: {
promptKey: BaseTextKey;
suggestionId: string;
prefillType: InstanceAiPrefillType;
}) => {
const prompt = i18n.baseText(payload.promptKey);
// Mirrors the real submitSuggestion, which reports the entry id.
activePrefill.value = {
text: prompt,
prefillType: payload.prefillType,
prefillId: payload.suggestionId,
};
submit(prompt);
},
});
return () =>
h('div', { 'data-test-id': 'instance-ai-input-stub' }, [
@ -853,8 +900,16 @@ describe('InstanceAiEmptyView', () => {
);
expect(thread.sendMessage).toHaveBeenCalledWith(
'When a new lead is created in my CRM, enrich it with Lemlist, score it based on fit, then update the lead if qualified and notify the sales team on Slack.',
undefined,
'test-push-ref',
{
authorship: {
kind: 'prefill',
prefillType: 'suggestion_catalog',
prefillId: 'score-my-leads',
promptModified: false,
},
attachments: undefined,
pushRef: 'test-push-ref',
},
);
expect(replaceMock).toHaveBeenCalledWith({
name: INSTANCE_AI_THREAD_VIEW,
@ -1077,7 +1132,11 @@ describe('InstanceAiEmptyView', () => {
'thread-placeholder',
PERSONAL_PROJECT_ID,
);
expect(thread.sendMessage).toHaveBeenCalledWith('hello', undefined, 'test-push-ref');
expect(thread.sendMessage).toHaveBeenCalledWith('hello', {
authorship: USER_TYPED_MESSAGE,
attachments: undefined,
pushRef: 'test-push-ref',
});
expect(replaceMock).toHaveBeenCalledWith({
name: INSTANCE_AI_THREAD_VIEW,
params: { threadId: 'thread-placeholder' },
@ -1094,7 +1153,11 @@ describe('InstanceAiEmptyView', () => {
await flushPromises();
await nextTick();
expect(thread.sendMessage).toHaveBeenCalledWith('hello', undefined, 'test-push-ref');
expect(thread.sendMessage).toHaveBeenCalledWith('hello', {
authorship: USER_TYPED_MESSAGE,
attachments: undefined,
pushRef: 'test-push-ref',
});
// Navigating would drop the user into a blank thread, and the destination cannot be
// handed the draft either: it reads localStorage once, synchronously, on mount.
expect(replaceMock).not.toHaveBeenCalled();
@ -1312,4 +1375,32 @@ describe('InstanceAiEmptyView', () => {
'Build me an invoice automation',
);
});
// The pre-fill tagging must not disturb what the agent receives: the suffix is
// still appended, and the card now reports itself instead of being recovered by
// matching that suffix against the message body.
it('sends a template example with its suffix intact and reports it as a pre-fill', async () => {
templateExamplesEnabled.value = true;
store.syncThread.mockResolvedValue(undefined);
const { getByTestId } = renderView();
await fireEvent.click(getByTestId('template-example-card'));
await flushPromises();
await fireEvent.click(getByTestId('instance-ai-input-stub-submit'));
await flushPromises();
expect(thread.sendMessage).toHaveBeenCalledWith(
'Build me an invoice automation\n\nAsk me questions to narrow down my use case and the tools I use to best personalize the example for my needs.',
{
authorship: {
kind: 'prefill',
prefillType: 'template_example',
promptModified: false,
},
attachments: undefined,
pushRef: 'test-push-ref',
},
);
});
});

View File

@ -2,7 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import userEvent from '@testing-library/user-event';
import { fireEvent, waitFor, within } from '@testing-library/vue';
import { setActivePinia, createPinia } from 'pinia';
import { defineComponent, h, type Component, type PropType } from 'vue';
import { defineComponent, h, ref, type Component, type PropType } from 'vue';
import type { BaseTextKey } from '@n8n/i18n';
import type { ITelemetryTrackProperties } from 'n8n-workflow';
import { createComponentRenderer } from '@/__tests__/render';
@ -93,6 +93,7 @@ const CustomInsertSuggestionsComponent = defineComponent({
suggestionId: 'custom-build-workflow',
suggestionKind: 'prompt',
position: 1,
prefillType: 'suggestion_catalog',
});
},
},
@ -177,6 +178,7 @@ const CustomRawPromptSuggestionsComponent = defineComponent({
suggestionKind: 'prompt',
position: 1,
telemetryPayload: { suggestion_source: 'v2_top_used_fallback' },
prefillType: 'suggestion_catalog',
}),
},
'Insert raw suggestion',
@ -216,6 +218,40 @@ const InputMenuStub = defineComponent({
},
});
const DirectSubmitHarness = defineComponent({
setup(_props, { emit }) {
const inputRef = ref<InstanceType<typeof InstanceAiInput> | null>(null);
return () =>
h('div', [
h(InstanceAiInput, {
ref: (el: unknown) => {
inputRef.value = el as InstanceType<typeof InstanceAiInput> | null;
},
...defaultProps(),
currentThreadId: '',
onSubmit: (...args: unknown[]) => emit('submit', ...args),
}),
h(
'button',
{
'data-test-id': 'harness-direct-submit',
onClick: () =>
inputRef.value?.submitSuggestion({
prompt: 'Score my leads automatically',
suggestionId: 'score-my-leads',
suggestionKind: 'quick_example',
position: 1,
prefillType: 'suggestion_catalog',
}),
},
'Direct submit',
),
]);
},
});
const renderDirectSubmitHarness = createComponentRenderer(DirectSubmitHarness);
const renderComponent = createComponentRenderer(InstanceAiInput, {
props: defaultProps(),
global: {
@ -480,6 +516,12 @@ describe('InstanceAiInput', () => {
'I want to build a new agent. Help me figure out what to build. Ask me what the main purpose of the agent is, what should trigger it into action, what apps, tools, or knowledge it should have access to, and whether I have a preference for the AI model used.',
undefined,
expect.any(Function),
{
kind: 'prefill',
prefillType: 'v1_opener',
prefillId: 'build-agent',
promptModified: false,
},
]);
expect(textbox).toHaveValue('');
});
@ -598,6 +640,149 @@ describe('InstanceAiInput', () => {
expect(submittedEvents).toHaveLength(0);
});
it('reports the pre-fill that filled the composer when an inserted suggestion is sent', async () => {
const { emitted, getByTestId } = renderComponent({
props: {
isStreaming: false,
suggestions,
suggestionsComponent: CustomInsertSuggestionsComponent,
suggestionCatalogVersion: 'v2',
currentThreadId: '',
},
});
await userEvent.click(getByTestId('custom-suggestion-insert'));
await userEvent.click(getByTestId('instance-ai-send-button'));
await waitFor(() => expect(emitted().submit?.[0]).toBeDefined());
expect(emittedArgument(emitted().submit?.[0], 3)).toEqual({
kind: 'prefill',
prefillType: 'suggestion_catalog',
prefillId: 'custom-build-workflow',
promptModified: false,
});
});
it('reports the pre-fill as modified when the inserted prompt is edited before sending', async () => {
const { emitted, getByRole, getByTestId } = renderComponent({
props: {
isStreaming: false,
suggestions,
suggestionsComponent: CustomInsertSuggestionsComponent,
suggestionCatalogVersion: 'v2',
currentThreadId: '',
},
});
await userEvent.click(getByTestId('custom-suggestion-insert'));
await userEvent.type(getByRole('textbox'), ' Also handle errors.');
await userEvent.click(getByTestId('instance-ai-send-button'));
await waitFor(() => expect(emitted().submit?.[0]).toBeDefined());
expect(emittedArgument(emitted().submit?.[0], 3)).toMatchObject({
kind: 'prefill',
prefillType: 'suggestion_catalog',
promptModified: true,
});
});
it('reports user authorship once a pre-filled prompt is cleared and replaced', async () => {
const { emitted, getByRole, getByTestId } = renderComponent({
props: {
isStreaming: false,
suggestions,
suggestionsComponent: CustomInsertSuggestionsComponent,
suggestionCatalogVersion: 'v2',
currentThreadId: '',
},
});
await userEvent.click(getByTestId('custom-suggestion-insert'));
const textbox = getByRole('textbox');
await userEvent.clear(textbox);
await userEvent.type(textbox, 'Build something unrelated from scratch');
await userEvent.click(getByTestId('instance-ai-send-button'));
await waitFor(() => expect(emitted().submit?.[0]).toBeDefined());
expect(emittedArgument(emitted().submit?.[0], 3)).toEqual({ kind: 'user_typed' });
});
// A refused send (a concurrency cap, a 429) puts the draft back. The retry has
// to stay attributed to the surface that wrote it, not read as user-typed.
it('keeps the pre-fill attribution when a refused send restores the draft', async () => {
const { emitted, getByTestId } = renderComponent({
props: {
isStreaming: false,
suggestions,
suggestionsComponent: CustomInsertSuggestionsComponent,
suggestionCatalogVersion: 'v2',
currentThreadId: '',
},
});
await userEvent.click(getByTestId('custom-suggestion-insert'));
await userEvent.click(getByTestId('instance-ai-send-button'));
await waitFor(() => expect(emitted().submit?.[0]).toBeDefined());
// The host refuses the send and hands the draft back.
const restoreDraft = emittedArgument(emitted().submit?.[0], 2);
if (typeof restoreDraft !== 'function') throw new Error('Expected a draft recovery callback');
expect(restoreDraft()).toBe(true);
await userEvent.click(getByTestId('instance-ai-send-button'));
await waitFor(() => expect(emitted().submit?.[1]).toBeDefined());
expect(emittedArgument(emitted().submit?.[1], 3)).toEqual({
kind: 'prefill',
prefillType: 'suggestion_catalog',
prefillId: 'custom-build-workflow',
promptModified: false,
});
});
it('reports a Tab-accepted contextual follow-up as a pre-fill', async () => {
const { emitted, getByRole, getByTestId } = renderComponent({
props: {
isStreaming: false,
suggestions,
contextualSuggestion: 'Add error handling to the workflow',
},
});
getByRole('textbox').focus();
await userEvent.keyboard('{Tab}');
await userEvent.click(getByTestId('instance-ai-send-button'));
await waitFor(() => expect(emitted().submit?.[0]).toBeDefined());
expect(emittedArgument(emitted().submit?.[0], 0)).toBe('Add error handling to the workflow');
expect(emittedArgument(emitted().submit?.[0], 3)).toEqual({
kind: 'prefill',
prefillType: 'contextual_followup',
promptModified: false,
});
});
// The split empty state sends a row straight off, with no insert step, so the
// composer was already empty and `resetDraftComposer` does not change it --
// the watcher never fires. Anything the user types next must not inherit the
// pre-fill that was just sent.
it('does not attribute a later typed message to a directly submitted suggestion', async () => {
const { emitted, getByRole, getByTestId } = renderDirectSubmitHarness();
await userEvent.click(getByTestId('harness-direct-submit'));
await waitFor(() => expect(emitted().submit?.[0]).toBeDefined());
expect(emittedArgument(emitted().submit?.[0], 3)).toMatchObject({
kind: 'prefill',
prefillType: 'suggestion_catalog',
});
await userEvent.type(getByRole('textbox'), 'Something else entirely');
await userEvent.click(getByTestId('instance-ai-send-button'));
await waitFor(() => expect(emitted().submit?.[1]).toBeDefined());
expect(emittedArgument(emitted().submit?.[1], 3)).toEqual({ kind: 'user_typed' });
});
it('submits typed text and attachments from the send button', async () => {
const { container, emitted, getByRole, getByTestId, queryByTestId } = renderComponent({
props: {
@ -631,6 +816,7 @@ describe('InstanceAiInput', () => {
}),
],
expect.any(Function),
{ kind: 'user_typed' },
],
]);
expect(textbox).toHaveValue('');
@ -821,7 +1007,7 @@ describe('InstanceAiInput', () => {
await userEvent.click(getByTestId('instance-ai-send-button'));
expect(emitted().submit).toEqual([
['Make the first workflow simpler', undefined, expect.any(Function)],
['Make the first workflow simpler', undefined, expect.any(Function), { kind: 'user_typed' }],
]);
});
@ -857,7 +1043,7 @@ describe('InstanceAiInput', () => {
await userEvent.type(getByRole('textbox'), 'Drop the third workflow{Enter}');
expect(emitted().submit).toEqual([
['Drop the third workflow', undefined, expect.any(Function)],
['Drop the third workflow', undefined, expect.any(Function), { kind: 'user_typed' }],
]);
});

View File

@ -65,6 +65,7 @@ describe('InstanceAiPromptSuggestions', () => {
suggestionId: 'build-agent',
suggestionKind: 'prompt',
position: 2,
prefillType: 'v1_opener',
},
],
[
@ -73,6 +74,7 @@ describe('InstanceAiPromptSuggestions', () => {
suggestionId: 'answer-support-requests',
suggestionKind: 'quick_example',
position: 3,
prefillType: 'v1_opener',
},
],
]);
@ -99,6 +101,7 @@ describe('InstanceAiPromptSuggestions', () => {
suggestionId: 'build-workflow',
suggestionKind: 'prompt',
position: 1,
prefillType: 'v1_opener',
},
],
]);

View File

@ -4,6 +4,11 @@ import userEvent from '@testing-library/user-event';
import { fireEvent } from '@testing-library/vue';
import { flushPromises } from '@vue/test-utils';
import { createTestingPinia } from '@pinia/testing';
import {
USER_TYPED_MESSAGE,
type InstanceAiMessageAuthorship,
type InstanceAiPrefillType,
} from '../prefills';
import { setActivePinia } from 'pinia';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore } from '@/__tests__/utils';
@ -170,15 +175,34 @@ const InstanceAiInputStub = defineComponent({
setup(props, { emit, expose }) {
const inputDraft = ref(inputState.initialDraft);
const hasAttachments = ref(inputState.hasAttachments);
// Mirrors the real composer: whatever pre-filled the box is reported on submit,
// and a pre-fill must arrive through setPrefill rather than setText.
const activePrefill = ref<{ text: string; prefillType: InstanceAiPrefillType } | null>(null);
const setText = (text: string) => {
inputDraft.value = text;
// Emptying the box drops the pre-fill, as the real composer's watcher does:
// a dismissed draft must not attribute whatever the user types next.
if (text.length === 0) activePrefill.value = null;
inputSetTextSpy(text);
};
const setPrefill = (prefill: { text: string; prefillType: InstanceAiPrefillType }) => {
setText(prefill.text);
activePrefill.value = { ...prefill };
};
const clearTextIfMatches = (text: string) => {
if (inputDraft.value === text) setText('');
};
const isDirty = () => inputDraft.value.trim().length > 0 || hasAttachments.value;
expose({ focus: inputFocusSpy, setText, clearTextIfMatches, isDirty });
const resolveAuthorship = (message: string): InstanceAiMessageAuthorship => {
const prefill = activePrefill.value;
if (!prefill) return USER_TYPED_MESSAGE;
return {
kind: 'prefill',
prefillType: prefill.prefillType,
promptModified: message !== prefill.text.trim(),
};
};
expose({ focus: inputFocusSpy, setText, setPrefill, clearTextIfMatches, isDirty });
return () =>
h('div', { 'data-test-id': 'instance-ai-input-stub' }, [
props.suggestions === undefined ? 'unset' : String(props.suggestions.length),
@ -240,18 +264,26 @@ const InstanceAiInputStub = defineComponent({
? planEditSubmitState.message
: inputDraft.value || 'Normal message';
const submittedHasAttachments = hasAttachments.value;
if (submittedHasAttachments) {
emit('submit', message, undefined, () => {
const authorship = resolveAuthorship(message);
const submittedPrefill = activePrefill.value;
// Mirrors the real composer: always provided, and it restores the
// pre-fill with the text so a retry stays attributed.
emit(
'submit',
message,
undefined,
() => {
if (isDirty()) return false;
setText(message);
activePrefill.value = submittedPrefill;
hasAttachments.value = submittedHasAttachments;
return true;
});
} else {
emit('submit', message, undefined);
}
},
authorship,
);
inputDraft.value = '';
hasAttachments.value = false;
activePrefill.value = null;
},
},
'Submit',
@ -344,7 +376,10 @@ const InstanceAiAgentPreviewStub = defineComponent({
agentId: props.agentId,
threadId: 'preview-session-1',
executionId: 'execution-1',
initialDraft: 'Fix the failed tool calls',
initialDraft: {
text: 'Fix the failed tool calls',
prefillType: 'handoff_agent_change_request',
},
}),
},
'Fix with Assistant',
@ -677,12 +712,12 @@ describe('InstanceAiThreadView', () => {
expect(getByTestId('setup-panel')).toHaveAttribute('data-project-id', 'project-2');
await fireEvent.click(getByTestId('instance-ai-input-submit'));
expect(thread.sendMessage).toHaveBeenCalledWith(
'Normal message',
undefined,
expect.any(String),
undefined,
);
expect(thread.sendMessage).toHaveBeenCalledWith('Normal message', {
authorship: USER_TYPED_MESSAGE,
attachments: undefined,
pushRef: expect.any(String),
handoffContext: undefined,
});
});
it('follows the selected workflow and project when tabs change', async () => {
@ -765,9 +800,12 @@ describe('InstanceAiThreadView', () => {
expect(getByTestId('instance-ai-input-context-chip')).toHaveTextContent('SEO Auditor session');
expect(getByTestId('instance-ai-input-draft')).toHaveTextContent('Fix the failed tool calls');
expect(localStorageState.store.has('n8n-instance-ai-handoff-context:thread-1')).toBe(true);
expect(localStorageState.store.get('n8n-instance-ai-composer-draft:thread-1')).toBe(
'Fix the failed tool calls',
);
expect(
JSON.parse(localStorageState.store.get('n8n-instance-ai-composer-draft:thread-1') ?? 'null'),
).toEqual({
text: 'Fix the failed tool calls',
prefillType: 'handoff_agent_change_request',
});
expect(getByTestId('instance-ai-agent-preview-stub')).toHaveAttribute(
'data-preview-session-id',
'preview-session-1',
@ -776,17 +814,21 @@ describe('InstanceAiThreadView', () => {
await user.click(getByTestId('instance-ai-input-submit'));
expect(thread.sendMessage).toHaveBeenCalledWith(
'Fix the failed tool calls',
undefined,
expect.any(String),
{
expect(thread.sendMessage).toHaveBeenCalledWith('Fix the failed tool calls', {
authorship: {
kind: 'prefill',
prefillType: 'handoff_agent_change_request',
promptModified: false,
},
attachments: undefined,
pushRef: expect.any(String),
handoffContext: {
source: 'agent-preview',
agentId: 'agent-1',
threadId: 'preview-session-1',
executionId: 'execution-1',
},
);
});
await vi.waitFor(() => {
expect(getByTestId('instance-ai-agent-preview-stub')).toHaveAttribute(
'data-preview-session-id',
@ -795,6 +837,29 @@ describe('InstanceAiThreadView', () => {
});
});
// The edit/accept distinction is the point of prompt_modified, so assert the
// true path on a send that actually goes through.
it('reports an edited pre-fill as modified when the send succeeds', async () => {
const { getByTestId, user } = await renderAgentArtifact();
store.updateThreadMetadata.mockResolvedValueOnce(undefined);
vi.mocked(thread.sendMessage).mockClear();
await user.click(getByTestId('instance-ai-agent-preview-fix-with-assistant'));
await user.click(getByTestId('instance-ai-input-edit-draft'));
await user.click(getByTestId('instance-ai-input-submit'));
expect(thread.sendMessage).toHaveBeenCalledWith(
'Edited user draft',
expect.objectContaining({
authorship: {
kind: 'prefill',
prefillType: 'handoff_agent_change_request',
promptModified: true,
},
}),
);
});
it('restores an edited fix draft and its attachments when sending fails', async () => {
const { getByTestId, user } = await renderAgentArtifact();
store.updateThreadMetadata.mockResolvedValueOnce(undefined);
@ -1045,7 +1110,10 @@ describe('InstanceAiThreadView', () => {
executionId: 'exec-1',
}),
);
stashPendingComposerDraft('thread-1', FIX_WITH_ASSISTANT_DRAFT);
stashPendingComposerDraft('thread-1', {
text: FIX_WITH_ASSISTANT_DRAFT,
prefillType: 'handoff_agent_change_request',
});
stashPendingAgentAttachment('thread-1', {
type: 'agent',
id: 'agent-1',
@ -1080,9 +1148,13 @@ describe('InstanceAiThreadView', () => {
await userEvent.click(getByTestId('instance-ai-input-submit'));
expect(thread.sendMessage).toHaveBeenCalledWith(
FIX_WITH_ASSISTANT_DRAFT,
[
expect(thread.sendMessage).toHaveBeenCalledWith(FIX_WITH_ASSISTANT_DRAFT, {
authorship: {
kind: 'prefill',
prefillType: 'handoff_agent_change_request',
promptModified: false,
},
attachments: [
{
type: 'agent',
id: 'agent-1',
@ -1090,14 +1162,14 @@ describe('InstanceAiThreadView', () => {
name: 'SEO Auditor',
},
],
expect.any(String),
{
pushRef: expect.any(String),
handoffContext: {
source: 'agent-preview',
agentId: 'agent-1',
threadId: 'preview-thread-1',
executionId: 'exec-1',
},
);
});
await vi.waitFor(() => {
expect(getByTestId('instance-ai-input-context-chip')).toHaveTextContent('');
expect(getByTestId('instance-ai-agent-preview-stub')).toHaveAttribute(
@ -1124,7 +1196,10 @@ describe('InstanceAiThreadView', () => {
threadId: 'preview-thread-1',
}),
);
stashPendingComposerDraft('thread-1', 'Fix the failed tool');
stashPendingComposerDraft('thread-1', {
text: 'Fix the failed tool',
prefillType: 'handoff_agent_change_request',
});
const { getByTestId } = renderView({ props: { threadId: 'thread-1' } });
@ -1187,16 +1262,16 @@ describe('InstanceAiThreadView', () => {
await userEvent.click(getByTestId('instance-ai-input-submit'));
expect(thread.sendMessage).toHaveBeenCalledWith(
'Normal message',
undefined,
expect.any(String),
{
expect(thread.sendMessage).toHaveBeenCalledWith('Normal message', {
authorship: USER_TYPED_MESSAGE,
attachments: undefined,
pushRef: expect.any(String),
handoffContext: {
source: 'agent-preview',
agentId: 'agent-1',
threadId: 'preview-thread-1',
},
);
});
await vi.waitFor(() => {
expect(localStorageState.store.has('n8n-instance-ai-handoff-context:thread-1')).toBe(false);
});
@ -1241,16 +1316,16 @@ describe('InstanceAiThreadView', () => {
await userEvent.click(getByTestId('instance-ai-input-submit'));
expect(thread.sendMessage).toHaveBeenCalledWith(
'Normal message',
undefined,
expect.any(String),
{
expect(thread.sendMessage).toHaveBeenCalledWith('Normal message', {
authorship: USER_TYPED_MESSAGE,
attachments: undefined,
pushRef: expect.any(String),
handoffContext: {
source: 'agent-preview',
agentId: 'agent-1',
threadId: 'preview-thread-1',
},
);
});
await vi.waitFor(() => {
expect(getByTestId('instance-ai-input-context-chip')).toHaveTextContent(
'SEO Auditor session',
@ -1261,17 +1336,16 @@ describe('InstanceAiThreadView', () => {
await userEvent.click(getByTestId('instance-ai-input-submit'));
expect(thread.sendMessage).toHaveBeenNthCalledWith(
2,
'Normal message',
undefined,
expect.any(String),
{
expect(thread.sendMessage).toHaveBeenNthCalledWith(2, 'Normal message', {
authorship: USER_TYPED_MESSAGE,
attachments: undefined,
pushRef: expect.any(String),
handoffContext: {
source: 'agent-preview',
agentId: 'agent-1',
threadId: 'preview-thread-1',
},
);
});
await vi.waitFor(() => {
expect(getByTestId('instance-ai-input-context-chip')).toHaveTextContent('');
expect(localStorageState.store.has('n8n-instance-ai-handoff-context:thread-1')).toBe(false);
@ -1323,10 +1397,9 @@ describe('InstanceAiThreadView', () => {
await userEvent.click(getByTestId('instance-ai-input-submit'));
expect(thread.sendMessage).toHaveBeenNthCalledWith(
1,
'Normal message',
[
expect(thread.sendMessage).toHaveBeenNthCalledWith(1, 'Normal message', {
authorship: USER_TYPED_MESSAGE,
attachments: [
{
type: 'agent',
id: 'agent-1',
@ -1335,18 +1408,17 @@ describe('InstanceAiThreadView', () => {
pending: true,
},
],
expect.any(String),
undefined,
);
pushRef: expect.any(String),
handoffContext: undefined,
});
expect(getPendingAgentAttachment('thread-1')).not.toBeNull();
expect(getByTestId('instance-ai-input-context-chip')).toHaveTextContent('New Agent');
await userEvent.click(getByTestId('instance-ai-input-submit'));
expect(thread.sendMessage).toHaveBeenNthCalledWith(
2,
'Normal message',
[
expect(thread.sendMessage).toHaveBeenNthCalledWith(2, 'Normal message', {
authorship: USER_TYPED_MESSAGE,
attachments: [
{
type: 'agent',
id: 'agent-1',
@ -1355,9 +1427,9 @@ describe('InstanceAiThreadView', () => {
pending: true,
},
],
expect.any(String),
undefined,
);
pushRef: expect.any(String),
handoffContext: undefined,
});
await vi.waitFor(() => {
expect(getPendingAgentAttachment('thread-1')).toBeNull();
expect(getByTestId('instance-ai-input-context-chip')).toHaveTextContent('');
@ -1365,13 +1437,12 @@ describe('InstanceAiThreadView', () => {
await userEvent.click(getByTestId('instance-ai-input-submit'));
expect(thread.sendMessage).toHaveBeenNthCalledWith(
3,
'Normal message',
undefined,
expect.any(String),
undefined,
);
expect(thread.sendMessage).toHaveBeenNthCalledWith(3, 'Normal message', {
authorship: USER_TYPED_MESSAGE,
attachments: undefined,
pushRef: expect.any(String),
handoffContext: undefined,
});
});
it('attaches the bound target when the new agent was persisted before the first message', async () => {
@ -1404,9 +1475,9 @@ describe('InstanceAiThreadView', () => {
});
await userEvent.click(getByTestId('instance-ai-input-submit'));
expect(thread.sendMessage).toHaveBeenCalledWith(
'Normal message',
[
expect(thread.sendMessage).toHaveBeenCalledWith('Normal message', {
authorship: USER_TYPED_MESSAGE,
attachments: [
{
type: 'agent',
id: 'agent-1',
@ -1414,9 +1485,9 @@ describe('InstanceAiThreadView', () => {
projectId: 'project-1',
},
],
expect.any(String),
undefined,
);
pushRef: expect.any(String),
handoffContext: undefined,
});
});
it('detaches dismissed saved-agent context without closing its preview', async () => {
@ -1450,12 +1521,12 @@ describe('InstanceAiThreadView', () => {
expect(getByTestId('instance-ai-input-context-chip')).toHaveTextContent('');
expect(preview).toBeInTheDocument();
await userEvent.click(getByTestId('instance-ai-input-submit'));
expect(thread.sendMessage).toHaveBeenCalledWith(
'Normal message',
undefined,
expect.any(String),
undefined,
);
expect(thread.sendMessage).toHaveBeenCalledWith('Normal message', {
authorship: USER_TYPED_MESSAGE,
attachments: undefined,
pushRef: expect.any(String),
handoffContext: undefined,
});
});
it('dismisses a pending preview-context chip without sending it', async () => {
@ -1469,7 +1540,10 @@ describe('InstanceAiThreadView', () => {
threadId: 'preview-thread-1',
}),
);
stashPendingComposerDraft('thread-1', 'Fix the failed tool');
stashPendingComposerDraft('thread-1', {
text: 'Fix the failed tool',
prefillType: 'handoff_agent_change_request',
});
const { getByTestId } = renderView({ props: { threadId: 'thread-1' } });
@ -1486,12 +1560,12 @@ describe('InstanceAiThreadView', () => {
await userEvent.click(getByTestId('instance-ai-input-submit'));
expect(thread.sendMessage).toHaveBeenCalledWith(
'Normal message',
undefined,
expect.any(String),
undefined,
);
expect(thread.sendMessage).toHaveBeenCalledWith('Normal message', {
authorship: USER_TYPED_MESSAGE,
attachments: undefined,
pushRef: expect.any(String),
handoffContext: undefined,
});
});
it('shows a sent preview-context chip and dismisses it through thread metadata', async () => {
@ -2666,12 +2740,12 @@ describe('InstanceAiThreadView', () => {
await getByTestId('instance-ai-input-submit').click();
expect(thread.sendMessage).toHaveBeenCalledWith(
'Normal message',
undefined,
expect.any(String),
undefined,
);
expect(thread.sendMessage).toHaveBeenCalledWith('Normal message', {
authorship: USER_TYPED_MESSAGE,
attachments: undefined,
pushRef: expect.any(String),
handoffContext: undefined,
});
expect(thread.confirmAction).not.toHaveBeenCalled();
});
});

View File

@ -12,6 +12,8 @@ import {
type InstanceAiCredentialDestination,
type InstanceAiTargetApproval,
} from '@n8n/api-types';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
import { USER_TYPED_MESSAGE } from '../prefills';
import {
createThreadRuntime,
getAgentBuilderTargetFromThreadMetadata,
@ -1083,7 +1085,9 @@ describe('createThreadRuntime - SSE and hydration', () => {
test('sendMessage pushes the optimistic user message synchronously and posts without syncing the thread', async () => {
mockPostMessage.mockResolvedValue({ runId: 'run-1' });
const sendPromise = activeRuntime(registry).sendMessage('first');
const sendPromise = activeRuntime(registry).sendMessage('first', {
authorship: USER_TYPED_MESSAGE,
});
expect(activeRuntime(registry).messages).toHaveLength(1);
expect(activeRuntime(registry).messages[0]).toMatchObject({
@ -1102,21 +1106,35 @@ describe('createThreadRuntime - SSE and hydration', () => {
mockPostMessage.mockResolvedValue({ runId: 'run-1' });
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await activeRuntime(registry).sendMessage('first');
await activeRuntime(registry).sendMessage('second');
await activeRuntime(registry).sendMessage('first', { authorship: USER_TYPED_MESSAGE });
await activeRuntime(registry).sendMessage('second', { authorship: USER_TYPED_MESSAGE });
expect(mockTelemetryTrack).toHaveBeenNthCalledWith(1, 'User sent builder message', {
thread_id: activeThreadId,
instance_id: 'instance-1',
is_first_message: true,
action_source: INSTANCE_AI_THREAD_SOURCE_FALLBACK,
});
expect(mockTelemetryTrack).toHaveBeenNthCalledWith(2, 'User sent builder message', {
thread_id: activeThreadId,
instance_id: 'instance-1',
is_first_message: false,
action_source: INSTANCE_AI_THREAD_SOURCE_FALLBACK,
});
expect(mockTelemetryTrack).toHaveBeenNthCalledWith(
1,
TELEMETRY_EVENT.INSTANCE_AI.USER_SENT_BUILDER_MESSAGE,
{
thread_id: activeThreadId,
instance_id: 'instance-1',
is_first_message: true,
action_source: INSTANCE_AI_THREAD_SOURCE_FALLBACK,
prefill_type: null,
prefill_id: null,
prompt_modified: null,
},
);
expect(mockTelemetryTrack).toHaveBeenNthCalledWith(
2,
TELEMETRY_EVENT.INSTANCE_AI.USER_SENT_BUILDER_MESSAGE,
{
thread_id: activeThreadId,
instance_id: 'instance-1',
is_first_message: false,
action_source: INSTANCE_AI_THREAD_SOURCE_FALLBACK,
prefill_type: null,
prefill_id: null,
prompt_modified: null,
},
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Missing or invalid thread source for message telemetry (thread ${activeThreadId})`,
@ -1135,14 +1153,20 @@ describe('createThreadRuntime - SSE and hydration', () => {
mockPostMessage.mockResolvedValue({ runId: 'run-1' });
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await runtime.sendMessage('hello');
await runtime.sendMessage('hello', { authorship: USER_TYPED_MESSAGE });
expect(mockTelemetryTrack).toHaveBeenCalledWith('User sent builder message', {
thread_id: activeThreadId,
instance_id: 'instance-1',
is_first_message: true,
action_source: 'canvas_action_button',
});
expect(mockTelemetryTrack).toHaveBeenCalledWith(
TELEMETRY_EVENT.INSTANCE_AI.USER_SENT_BUILDER_MESSAGE,
{
thread_id: activeThreadId,
instance_id: 'instance-1',
is_first_message: true,
action_source: 'canvas_action_button',
prefill_type: null,
prefill_id: null,
prompt_modified: null,
},
);
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
});
@ -1157,14 +1181,20 @@ describe('createThreadRuntime - SSE and hydration', () => {
mockPostMessage.mockResolvedValue({ runId: 'run-1' });
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await runtime.sendMessage('hello');
await runtime.sendMessage('hello', { authorship: USER_TYPED_MESSAGE });
expect(mockTelemetryTrack).toHaveBeenCalledWith('User sent builder message', {
thread_id: activeThreadId,
instance_id: 'instance-1',
is_first_message: true,
action_source: INSTANCE_AI_THREAD_SOURCE_FALLBACK,
});
expect(mockTelemetryTrack).toHaveBeenCalledWith(
TELEMETRY_EVENT.INSTANCE_AI.USER_SENT_BUILDER_MESSAGE,
{
thread_id: activeThreadId,
instance_id: 'instance-1',
is_first_message: true,
action_source: INSTANCE_AI_THREAD_SOURCE_FALLBACK,
prefill_type: null,
prefill_id: null,
prompt_modified: null,
},
);
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining(
`Missing or invalid thread source for message telemetry (thread ${activeThreadId})`,
@ -1173,10 +1203,77 @@ describe('createThreadRuntime - SSE and hydration', () => {
warnSpy.mockRestore();
});
test('sendMessage reports the pre-fill type and id on the message event', async () => {
mockPostMessage.mockResolvedValue({ runId: 'run-1' });
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await activeRuntime(registry).sendMessage('Build a workflow that scrapes a page', {
authorship: {
kind: 'prefill',
prefillType: 'suggestion_catalog',
prefillId: 'v4-engineering-data-management-1',
promptModified: true,
},
});
expect(mockTelemetryTrack).toHaveBeenCalledWith(
TELEMETRY_EVENT.INSTANCE_AI.USER_SENT_BUILDER_MESSAGE,
{
thread_id: activeThreadId,
instance_id: 'instance-1',
is_first_message: true,
action_source: INSTANCE_AI_THREAD_SOURCE_FALLBACK,
prefill_type: 'suggestion_catalog',
prefill_id: 'v4-engineering-data-management-1',
prompt_modified: true,
},
);
warnSpy.mockRestore();
});
test('sendMessage treats an auto-sent pre-fill as unmodified', async () => {
mockPostMessage.mockResolvedValue({ runId: 'run-1' });
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await activeRuntime(registry).sendMessage('The execution failed.', {
authorship: { kind: 'prefill', prefillType: 'handoff_execution_error' },
});
expect(mockTelemetryTrack).toHaveBeenCalledWith(
TELEMETRY_EVENT.INSTANCE_AI.USER_SENT_BUILDER_MESSAGE,
expect.objectContaining({
prefill_type: 'handoff_execution_error',
prefill_id: null,
prompt_modified: false,
}),
);
warnSpy.mockRestore();
});
test('sendMessage nulls every pre-fill property for a message the user typed', async () => {
mockPostMessage.mockResolvedValue({ runId: 'run-1' });
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await activeRuntime(registry).sendMessage('hello', { authorship: USER_TYPED_MESSAGE });
expect(mockTelemetryTrack).toHaveBeenCalledWith(
TELEMETRY_EVENT.INSTANCE_AI.USER_SENT_BUILDER_MESSAGE,
expect.objectContaining({
prefill_type: null,
prefill_id: null,
prompt_modified: null,
}),
);
warnSpy.mockRestore();
});
test('sendMessage forwards pushRef to postMessage', async () => {
mockPostMessage.mockResolvedValue({ runId: 'run-1' });
await activeRuntime(registry).sendMessage('hello', undefined, 'iframe-push-ref-123');
await activeRuntime(registry).sendMessage('hello', {
authorship: USER_TYPED_MESSAGE,
pushRef: 'iframe-push-ref-123',
});
expect(mockPostMessage).toHaveBeenCalledWith(
expect.anything(),
@ -1202,7 +1299,10 @@ describe('createThreadRuntime - SSE and hydration', () => {
},
};
await activeRuntime(registry).sendMessage('hello', undefined, undefined, context);
await activeRuntime(registry).sendMessage('hello', {
authorship: USER_TYPED_MESSAGE,
handoffContext: context,
});
expect(activeRuntime(registry).messages[0]).toMatchObject({
role: 'user',
@ -1225,7 +1325,7 @@ describe('createThreadRuntime - SSE and hydration', () => {
test('sendMessage omits pushRef when not provided', async () => {
mockPostMessage.mockResolvedValue({ runId: 'run-1' });
await activeRuntime(registry).sendMessage('hello');
await activeRuntime(registry).sendMessage('hello', { authorship: USER_TYPED_MESSAGE });
expect(mockPostMessage).toHaveBeenCalledWith(
expect.anything(),
@ -1250,7 +1350,7 @@ describe('createThreadRuntime - SSE and hydration', () => {
// Clear capturedInstance so we can verify a *new* EventSource is created
capturedInstance = null;
await activeRuntime(registry).sendMessage('hello');
await activeRuntime(registry).sendMessage('hello', { authorship: USER_TYPED_MESSAGE });
// sendMessage should have re-opened an EventSource before posting
expect(capturedInstance).not.toBeNull();
@ -1260,7 +1360,9 @@ describe('createThreadRuntime - SSE and hydration', () => {
test('sendMessage rolls back the optimistic message when postMessage fails', async () => {
mockPostMessage.mockRejectedValueOnce(new Error('post failed'));
const sendPromise = activeRuntime(registry).sendMessage('first');
const sendPromise = activeRuntime(registry).sendMessage('first', {
authorship: USER_TYPED_MESSAGE,
});
expect(activeRuntime(registry).isSendingMessage).toBe(true);
expect(activeRuntime(registry).messages).toHaveLength(1);
@ -1279,7 +1381,9 @@ describe('createThreadRuntime - SSE and hydration', () => {
test('sendMessage sets activeRunId from postMessage response before run-start', async () => {
mockPostMessage.mockResolvedValue({ runId: 'run-from-post' });
const sendPromise = activeRuntime(registry).sendMessage('hello');
const sendPromise = activeRuntime(registry).sendMessage('hello', {
authorship: USER_TYPED_MESSAGE,
});
await vi.waitFor(() => {
expect(activeRuntime(registry).activeRunId).toBe('run-from-post');
});
@ -1525,7 +1629,7 @@ describe('createThreadRuntime - loadThreadStatus and HITL reconnect', () => {
},
];
mockPostMessage.mockResolvedValueOnce({ runId: 'run-live-elsewhere' });
await runtime.sendMessage('go');
await runtime.sendMessage('go', { authorship: USER_TYPED_MESSAGE });
expect(runtime.activeRunId).toBe('run-live-elsewhere');
mockFetchThreadStatus.mockResolvedValue({
hasActiveRun: false,
@ -1621,7 +1725,7 @@ describe('createThreadRuntime - loadThreadStatus and HITL reconnect', () => {
const statusPromise = runtime.loadThreadStatus();
mockPostMessage.mockResolvedValueOnce({ runId: 'run-new' });
await runtime.sendMessage('new request');
await runtime.sendMessage('new request', { authorship: USER_TYPED_MESSAGE });
expect(runtime.activeRunId).toBe('run-new');
resolveStatus?.({ hasActiveRun: false, isSuspended: false, backgroundTasks: [] });
@ -2494,7 +2598,9 @@ describe('createThreadRuntime - "Builder generation stalled" telemetry', () => {
test('arms when a message send starts a run while the stream stays silent', async () => {
mockPostMessage.mockResolvedValueOnce({ runId: 'run-silent' });
await activeRuntime(registry).sendMessage('build me a workflow');
await activeRuntime(registry).sendMessage('build me a workflow', {
authorship: USER_TYPED_MESSAGE,
});
// Let the isGenerationPending watcher observe the new run id.
await nextTick();

View File

@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest';
import { INSTANCE_AI_THREAD_SOURCE_FALLBACK } from '@n8n/api-types';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
import {
INSTANCE_AI_PREFILL_TYPE_FALLBACK,
INSTANCE_AI_PREFILL_TYPES,
isInstanceAiPrefillType,
isMessageAuthorship,
} from '../prefills';
describe('isInstanceAiPrefillType', () => {
it('accepts every registered pre-fill type', () => {
for (const type of INSTANCE_AI_PREFILL_TYPES) {
expect(isInstanceAiPrefillType(type)).toBe(true);
}
});
it.each([['template_prefill'], [''], [null], [undefined], [42]])(
'rejects %s',
(value: unknown) => {
expect(isInstanceAiPrefillType(value)).toBe(false);
},
);
// The fallback is reportable but not declarable, so a new surface cannot pick it.
it('rejects the read-path fallback', () => {
expect(isInstanceAiPrefillType(INSTANCE_AI_PREFILL_TYPE_FALLBACK)).toBe(false);
});
});
describe('isMessageAuthorship', () => {
it('accepts a user-typed authorship', () => {
expect(isMessageAuthorship({ kind: 'user_typed' })).toBe(true);
});
it('accepts a pre-fill authorship with a registered type', () => {
expect(isMessageAuthorship({ kind: 'prefill', prefillType: 'handoff_fix_with_ai' })).toBe(true);
});
// A stash written by an older deploy, or a hand-edited one, must not be trusted:
// an unrecognised type would reach telemetry and break the enum downstream.
it('rejects a pre-fill authorship carrying an unregistered type', () => {
expect(isMessageAuthorship({ kind: 'prefill', prefillType: 'template_prefill' })).toBe(false);
});
it('rejects a pre-fill authorship with no type at all', () => {
expect(isMessageAuthorship({ kind: 'prefill' })).toBe(false);
});
it.each([[null], [undefined], ['user_typed'], [{ kind: 'something_else' }]])(
'rejects %s',
(value: unknown) => {
expect(isMessageAuthorship(value)).toBe(false);
},
);
});
// `@n8n/api-types` owns the list and the registry builds its enum from it, so
// these check the wiring rather than guarding drift: that `prefill_type` really
// is the shared enum (not a loose string), that it admits the read-path
// fallback, and that it still rejects a value nobody defines.
describe('the prefill_type property on the message event', () => {
const event = TELEMETRY_EVENT.INSTANCE_AI.USER_SENT_BUILDER_MESSAGE;
const payload = {
thread_id: 'thread-1',
instance_id: 'instance-1',
is_first_message: true,
action_source: 'assistant_page',
prefill_id: null,
prompt_modified: false,
};
it.each([...INSTANCE_AI_PREFILL_TYPES, INSTANCE_AI_PREFILL_TYPE_FALLBACK].map((t) => [t]))(
'accepts %s',
(prefillType: string) => {
expect(event.getValidationError({ ...payload, prefill_type: prefillType })).toBeNull();
},
);
it('accepts a null prefill_type for a message the user typed', () => {
expect(
event.getValidationError({ ...payload, prefill_type: null, prompt_modified: null }),
).toBeNull();
});
it('rejects a prefill_type nothing defines', () => {
expect(
event.getValidationError({ ...payload, prefill_type: 'template_prefill' }),
).not.toBeNull();
});
it('accepts the legacy action_source fallback', () => {
expect(
event.getValidationError({
...payload,
action_source: INSTANCE_AI_THREAD_SOURCE_FALLBACK,
prefill_type: null,
}),
).toBeNull();
});
});

View File

@ -90,7 +90,10 @@ describe('useInstanceAiAgentPreviewHandoff', () => {
agentId: 'agent-1',
threadId: 'thread-1',
executionId: 'exec-1',
initialDraft: FIX_WITH_ASSISTANT_DRAFT,
initialDraft: {
text: FIX_WITH_ASSISTANT_DRAFT,
prefillType: 'handoff_agent_change_request',
},
});
expect(openAgentArtifactThreadMock).toHaveBeenCalledWith(
@ -111,7 +114,10 @@ describe('useInstanceAiAgentPreviewHandoff', () => {
threadId: 'thread-1',
executionId: 'exec-1',
},
initialDraft: FIX_WITH_ASSISTANT_DRAFT,
initialDraft: {
text: FIX_WITH_ASSISTANT_DRAFT,
prefillType: 'handoff_agent_change_request',
},
},
);
expect(trackMock).toHaveBeenCalledWith(

View File

@ -1,3 +1,4 @@
import { INSTANCE_AI_PREFILL_TYPE_FALLBACK } from '../prefills';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => ({
@ -155,11 +156,32 @@ describe('useInstanceAiHandoff', () => {
expect(getPendingHandoffContext('thread-1')).toBeNull();
});
it('keeps a pending composer draft until it is explicitly cleared', () => {
stashPendingComposerDraft('thread-1', 'Fix this tool failure');
// A raw string is what a previous deploy stashed. Both surfaces that stash a
// draft are hand-offs, so naming either one would mis-attribute the other --
// report the read-path fallback instead of guessing.
it('replays a composer draft stashed by a previous deploy under the fallback type', () => {
localStorage.setItem('n8n-instance-ai-composer-draft:thread-1', 'Fix the failed tool calls');
expect(getPendingComposerDraft('thread-1')).toBe('Fix this tool failure');
expect(getPendingComposerDraft('thread-1')).toBe('Fix this tool failure');
expect(getPendingComposerDraft('thread-1')).toEqual({
text: 'Fix the failed tool calls',
prefillType: INSTANCE_AI_PREFILL_TYPE_FALLBACK,
});
});
it('keeps a pending composer draft until it is explicitly cleared', () => {
stashPendingComposerDraft('thread-1', {
text: 'Fix this tool failure',
prefillType: 'handoff_credential_setup',
});
expect(getPendingComposerDraft('thread-1')).toEqual({
text: 'Fix this tool failure',
prefillType: 'handoff_credential_setup',
});
expect(getPendingComposerDraft('thread-1')).toEqual({
text: 'Fix this tool failure',
prefillType: 'handoff_credential_setup',
});
clearPendingComposerDraft('thread-1');
expect(getPendingComposerDraft('thread-1')).toBeNull();
});
@ -175,17 +197,18 @@ describe('useInstanceAiHandoff', () => {
origin: 'internal' as const,
};
const threadId = await provisionContextOnlyThread(
'project-1',
context,
launch,
'Fix this tool failure',
);
const threadId = await provisionContextOnlyThread('project-1', context, launch, {
text: 'Fix this tool failure',
prefillType: 'handoff_credential_setup',
});
expect(threadId).toBe('thread-1');
expect(mocks.syncThread).toHaveBeenCalledWith('thread-1', 'project-1', launch);
expect(getPendingHandoffContext('thread-1')).toEqual(context);
expect(getPendingComposerDraft('thread-1')).toBe('Fix this tool failure');
expect(getPendingComposerDraft('thread-1')).toEqual({
text: 'Fix this tool failure',
prefillType: 'handoff_credential_setup',
});
});
it('keeps a pending agent attachment until it is explicitly cleared', () => {
@ -210,14 +233,20 @@ describe('useInstanceAiHandoff', () => {
threadId: 'preview-thread-1',
});
stashPendingHandoffContext('thread-1', context);
stashPendingComposerDraft('thread-1', 'Fix the failed tool calls');
stashPendingComposerDraft('thread-1', {
text: 'Fix the failed tool calls',
prefillType: 'handoff_agent_change_request',
});
stashPendingAgentAttachment('thread-1', {
type: 'agent',
id: 'agent-1',
projectId: 'project-1',
});
stashPendingFirstMessage('thread-1', { message: 'Set up the credential' });
stashPendingFirstMessage('thread-1', {
message: 'Set up the credential',
authorship: { kind: 'prefill', prefillType: 'handoff_credential_setup' },
});
clearPendingThreadHandoff('thread-1');
@ -229,8 +258,62 @@ describe('useInstanceAiHandoff', () => {
expect(consumePendingFirstMessage('thread-1')).toBeNull();
});
// A stashed opener is always n8n-authored -- every stash comes from a hand-off.
// Reporting it as user-typed would be the misclassification the type prevents.
it('replays an opening message stashed by a previous deploy as an untyped pre-fill', () => {
localStorage.setItem(
'n8n-instance-ai-first-message:thread-1',
JSON.stringify({ message: 'Set up the credential' }),
);
expect(consumePendingFirstMessage('thread-1')).toEqual({
message: 'Set up the credential',
authorship: { kind: 'prefill', prefillType: INSTANCE_AI_PREFILL_TYPE_FALLBACK },
});
});
// The draft is text the user is about to send, so an unreadable envelope must
// not discard it.
it('keeps a composer draft whose stored envelope is unusable', () => {
localStorage.setItem('n8n-instance-ai-composer-draft:thread-1', JSON.stringify({ nope: 1 }));
expect(getPendingComposerDraft('thread-1')).toEqual({
text: JSON.stringify({ nope: 1 }),
prefillType: INSTANCE_AI_PREFILL_TYPE_FALLBACK,
});
});
// A retired or mistyped value must not cost the user their draft text, and must
// certainly not put the raw envelope in the composer for them to send.
it('keeps the draft text when the stored pre-fill type is not recognised', () => {
localStorage.setItem(
'n8n-instance-ai-composer-draft:thread-1',
JSON.stringify({ text: 'Fix the failed tool calls', prefillType: 'retired_catalog' }),
);
expect(getPendingComposerDraft('thread-1')).toEqual({
text: 'Fix the failed tool calls',
prefillType: INSTANCE_AI_PREFILL_TYPE_FALLBACK,
});
});
it('round-trips a composer draft stashed under the fallback type', () => {
stashPendingComposerDraft('thread-1', {
text: 'Fix the failed tool calls',
prefillType: INSTANCE_AI_PREFILL_TYPE_FALLBACK,
});
expect(getPendingComposerDraft('thread-1')).toEqual({
text: 'Fix the failed tool calls',
prefillType: INSTANCE_AI_PREFILL_TYPE_FALLBACK,
});
});
it('drops a stashed opening message without consuming it', () => {
stashPendingFirstMessage('thread-1', { message: 'Set up the credential' });
stashPendingFirstMessage('thread-1', {
message: 'Set up the credential',
authorship: { kind: 'prefill', prefillType: 'handoff_credential_setup' },
});
clearPendingFirstMessage('thread-1');
@ -240,6 +323,7 @@ describe('useInstanceAiHandoff', () => {
it('round-trips an opening message with its attachments so a refused send can requeue it', () => {
const payload: PendingFirstMessage = {
message: 'Fix this workflow',
authorship: { kind: 'prefill', prefillType: 'handoff_credential_setup' },
attachments: [{ type: 'agent', id: 'agent-1', projectId: 'project-1' }],
context: buildInstanceAiAgentPreviewHandoffContext({
agentId: 'agent-1',
@ -278,7 +362,13 @@ describe('useInstanceAiHandoff', () => {
origin: 'internal',
sourceContext: { agentId: 'agent-1' },
},
{ context, initialDraft: 'Fix the failed tool calls' },
{
context,
initialDraft: {
text: 'Fix the failed tool calls',
prefillType: 'handoff_agent_change_request',
},
},
);
expect(opened).toBe(true);
@ -305,7 +395,10 @@ describe('useInstanceAiHandoff', () => {
projectId: 'project-1',
});
expect(getPendingHandoffContext('thread-1')).toEqual(context);
expect(getPendingComposerDraft('thread-1')).toBe('Fix the failed tool calls');
expect(getPendingComposerDraft('thread-1')).toEqual({
text: 'Fix the failed tool calls',
prefillType: 'handoff_agent_change_request',
});
expect(mocks.getOrCreateRuntime).not.toHaveBeenCalled();
expect(mocks.sendMessage).not.toHaveBeenCalled();
expect(mocks.routerPush).toHaveBeenCalledWith({
@ -325,7 +418,13 @@ describe('useInstanceAiHandoff', () => {
const opened = await openAgentArtifactThread(
{ type: 'agent', id: 'agent-1', projectId: 'project-1' },
{ source: 'agent_preview', origin: 'internal' },
{ context, initialDraft: 'Fix the failed tool calls' },
{
context,
initialDraft: {
text: 'Fix the failed tool calls',
prefillType: 'handoff_agent_change_request',
},
},
);
expect(opened).toBe(false);
@ -358,10 +457,12 @@ describe('useInstanceAiHandoff', () => {
it('routes startThread to the assistant instead of sending the opening turn', async () => {
const { startThread } = useInstanceAiHandoff();
await startThread('project-1', 'Fix my workflow', {
source: 'canvas_action_button',
origin: 'internal',
});
await startThread(
'project-1',
'Fix my workflow',
{ kind: 'prefill', prefillType: 'handoff_execution_error' },
{ source: 'canvas_action_button', origin: 'internal' },
);
expect(mocks.syncThread).not.toHaveBeenCalled();
expect(mocks.sendMessage).not.toHaveBeenCalled();

View File

@ -533,12 +533,11 @@ describe('useSetupPanelActions', () => {
await expect(actions.executeWorkflow()).resolves.toBe(true);
expect(sendMessage).toHaveBeenCalledExactlyOnceWith(
'Run a test execution of this workflow.',
undefined,
useRootStore().pushRef,
{ source: 'setup-panel-execute', workflowId: WORKFLOW_ID },
);
expect(sendMessage).toHaveBeenCalledExactlyOnceWith('Run a test execution of this workflow.', {
authorship: { kind: 'prefill', prefillType: 'handoff_setup_panel_execute' },
pushRef: useRootStore().pushRef,
handoffContext: { source: 'setup-panel-execute', workflowId: WORKFLOW_ID },
});
});
it('does not send Execute without an active artifact workflow', async () => {

View File

@ -18,6 +18,12 @@ import { INSTANCE_AI_EMPTY_STATE_SUGGESTIONS_VERSION } from '../emptyStateSugges
import { useInstanceAiPromptSuggestionsTelemetry } from '../instanceAiPromptSuggestions.telemetry';
import type { ContextChip } from '../instanceAi.contextChip';
import { useInstanceAiStore } from '../instanceAi.store';
import {
USER_TYPED_MESSAGE,
type InstanceAiMessageAuthorship,
type InstanceAiPrefillType,
type InstanceAiPrefillTypeReported,
} from '../prefills';
import { mergeNodeSets } from '../utils/buildNodesAttachment';
type AmendContext = { agentId: string; role: string } | null;
@ -35,6 +41,8 @@ type SuggestionSelectionPayload = SuggestionPromptPayload & {
suggestionKind: 'prompt' | 'quick_example';
position: number;
telemetryPayload?: ITelemetryTrackProperties;
/** Required so a new catalog cannot emit suggestions that report as user-typed. */
prefillType: InstanceAiPrefillType;
};
type SelectedSuggestionDraft = SuggestionSelectionPayload & {
originalPrompt: string;
@ -46,6 +54,12 @@ type SuggestionsCyclePayload = {
telemetryPayload?: ITelemetryTrackProperties;
};
type SuggestionPreviewPayload = BaseTextKey | { prompt: string } | null;
type ActivePrefill = {
/** The text as the pre-fill wrote it, so an edit can be detected. */
text: string;
prefillType: InstanceAiPrefillTypeReported;
prefillId?: string;
};
const SUGGESTIONS_TRANSITION_DURATION = { enter: 450, leave: 320 };
const DEFAULT_AUTOSIZE_ROWS = 3;
const DEFAULT_MAX_AUTOSIZE_ROWS = 6;
@ -96,8 +110,14 @@ const props = withDefaults(
const emit = defineEmits<{
// `restoreDraft` puts the cleared draft back when the send fails. It returns
// false when the user has already typed something newer, so the caller can
// tell whether the draft was recovered.
submit: [message: string, attachments?: InstanceAiAttachment[], restoreDraft?: () => boolean];
// tell whether the draft was recovered. It also restores the pre-fill the
// draft came from, so a retry stays attributed to the surface that wrote it.
submit: [
message: string,
attachments: InstanceAiAttachment[] | undefined,
restoreDraft: () => boolean,
authorship: InstanceAiMessageAuthorship,
];
stop: [];
'dismiss-context-chip': [];
'workflow-preview': [workflowFile: string | null];
@ -118,6 +138,12 @@ const chatInputRef = ref<InstanceType<typeof ChatInputBase> | null>(null);
// Experiment cleanup: remove with instanceAiPromptSuggestionsV2.
const previewPrompt = ref<string | null>(null);
const selectedSuggestionDraft = ref<SelectedSuggestionDraft | null>(null);
/**
* What pre-filled the composer, so the submit can report who wrote the text.
* Separate from `selectedSuggestionDraft`, which is scoped to the suggestion
* experiment; this also covers template examples and hand-off drafts.
*/
const activePrefill = ref<ActivePrefill | null>(null);
// Experiment cleanup: remove with instanceAiSplitEmptyState.
const typedPreview = ref('');
@ -171,6 +197,20 @@ function setTextIfEmpty(text: string) {
if (!inputText.value.trim()) inputText.value = text;
}
/**
* Put n8n-authored text in the composer. Pre-fills must come through here
* rather than `setText` so the submit can attribute them; `setText` and
* friends stay for restoring a draft the user wrote.
*/
function setPrefill(prefill: {
text: string;
prefillType: InstanceAiPrefillTypeReported;
prefillId?: string;
}) {
inputText.value = prefill.text;
activePrefill.value = { ...prefill };
}
function clearTextIfMatches(text: string) {
if (inputText.value === text) inputText.value = '';
}
@ -183,6 +223,7 @@ defineExpose({
focus,
appendText,
setText,
setPrefill,
setTextIfEmpty,
clearTextIfMatches,
isDirty,
@ -288,6 +329,7 @@ watch(
watch(inputText, (text) => {
if (text.length === 0) {
selectedSuggestionDraft.value = null;
activePrefill.value = null;
}
});
@ -295,9 +337,27 @@ function emitSubmittedMessage(
message: string,
attachments: InstanceAiAttachment[] | undefined,
restoreDraft: () => boolean,
authorship: InstanceAiMessageAuthorship,
) {
previewPrompt.value = null;
emit('submit', message, attachments, restoreDraft);
emit('submit', message, attachments, restoreDraft, authorship);
}
/**
* The composer is the only place that knows whether the text came from a
* pre-fill, so it resolves authorship for every send that leaves it.
*/
function resolveAuthorship(
message: string,
prefill: ActivePrefill | null,
): InstanceAiMessageAuthorship {
if (!prefill) return USER_TYPED_MESSAGE;
return {
kind: 'prefill',
prefillType: prefill.prefillType,
...(prefill.prefillId ? { prefillId: prefill.prefillId } : {}),
promptModified: message !== prefill.text.trim(),
};
}
function resetDraftComposer({ keepAttachments = false } = {}) {
@ -326,19 +386,48 @@ function restorePlanFeedbackDraft(message: string) {
return true;
}
/**
* Puts a submitted draft back after a refused send. Returns false when the user
* has already typed something newer, so the caller knows the draft is gone.
*
* The pre-fill snapshot is restored with the text -- always after it, since an
* empty assignment clears the pre-fill -- so retrying stays attributed to the
* surface that wrote the draft rather than reporting as user-typed.
*/
function restoreSubmittedDraft(
message: string,
files: File[],
resources: InstanceAiResourceAttachment[],
prefill: ActivePrefill | null,
) {
if (isDirty()) return false;
const restorePrefill = () => {
activePrefill.value = prefill ? { ...prefill } : null;
};
if (isDirty()) {
// Dirty only because something was attached after the send: the text slot is
// still free, so give the draft back and leave the new attachments alone.
if (inputText.value.trim()) return false;
inputText.value = message;
restorePrefill();
return true;
}
inputText.value = message;
restorePrefill();
attachedFiles.value = [...files];
attachedResources.value = [...resources];
return true;
}
function submitComposerMessage(message: string, attachments?: InstanceAiAttachment[]) {
/**
* `prefill` is the snapshot its caller took when it read the message, not live
* state: `handleSubmit` awaits file conversion in between, and the composer can
* be edited during that await.
*/
function submitComposerMessage(
message: string,
attachments: InstanceAiAttachment[] | undefined,
prefill: ActivePrefill | null,
) {
if (!canSubmitMessage(message, attachments?.length ?? 0)) {
return;
}
@ -348,7 +437,15 @@ function submitComposerMessage(message: string, attachments?: InstanceAiAttachme
// instead of being dropped on a send that could never carry it. A suggestion
// draft can reach here, but feedback on a plan is not a suggestion submission.
if (props.isAwaitingPlanReview) {
emitSubmittedMessage(message, undefined, () => restorePlanFeedbackDraft(message));
// Feedback on a plan is the user's own answer, so it reports as typed even
// when a pre-filled draft is what reached here -- the same reason this path
// skips the suggestion-submitted event below.
emitSubmittedMessage(
message,
undefined,
() => restorePlanFeedbackDraft(message),
USER_TYPED_MESSAGE,
);
resetDraftComposer({ keepAttachments: true });
return;
}
@ -357,8 +454,11 @@ function submitComposerMessage(message: string, attachments?: InstanceAiAttachme
const submittedFiles = [...attachedFiles.value];
const submittedResources = [...attachedResources.value];
emitSubmittedMessage(message, attachments, () =>
restoreSubmittedDraft(message, submittedFiles, submittedResources),
emitSubmittedMessage(
message,
attachments,
() => restoreSubmittedDraft(message, submittedFiles, submittedResources, prefill),
resolveAuthorship(message, prefill),
);
resetDraftComposer();
}
@ -369,18 +469,28 @@ function submitComposerMessage(message: string, attachments?: InstanceAiAttachme
function submitSuggestion(payload: SuggestionSelectionPayload) {
const prompt = getSuggestionPrompt(payload);
selectedSuggestionDraft.value = { ...payload, originalPrompt: prompt };
submitComposerMessage(prompt);
// Passed by argument, not staged in `activePrefill`: the composer is already
// empty on this path, so `resetDraftComposer` leaves `inputText` unchanged and
// the watcher never clears it -- a later typed message would inherit it.
submitComposerMessage(prompt, undefined, {
text: prompt,
prefillType: payload.prefillType,
prefillId: payload.suggestionId,
});
}
async function handleSubmit() {
const text = inputText.value.trim();
// Read with the text: the file conversion below awaits, and an edit during it
// would otherwise pair this message with the next pre-fill's authorship.
const prefill = activePrefill.value;
if (!canSubmitMessage(text, attachedFiles.value.length + attachedResources.value.length)) {
return;
}
// Plan feedback carries no attachments, so skip encoding the staged files.
if (props.isAwaitingPlanReview) {
submitComposerMessage(text);
submitComposerMessage(text, undefined, null);
return;
}
@ -394,7 +504,7 @@ async function handleSubmit() {
: [];
const attachments = [...fileAttachments, ...attachedResources.value];
submitComposerMessage(text, attachments.length ? attachments : undefined);
submitComposerMessage(text, attachments.length ? attachments : undefined, prefill);
}
function removeResource(index: number) {
@ -430,7 +540,8 @@ function handleStop() {
function handleTabAutocomplete() {
if (!inputText.value && props.contextualSuggestion) {
inputText.value = props.contextualSuggestion;
// n8n wrote this follow-up, so accepting it is a pre-fill like any other.
setPrefill({ text: props.contextualSuggestion, prefillType: 'contextual_followup' });
}
}
@ -527,6 +638,11 @@ async function handleSuggestionInsert(payload: SuggestionSelectionPayload) {
...payload,
originalPrompt: prompt,
};
activePrefill.value = {
text: prompt,
prefillType: payload.prefillType,
prefillId: payload.suggestionId,
};
inputText.value = prompt;
await nextTick();

View File

@ -8,13 +8,14 @@ import {
isPromptSuggestion,
type InstanceAiEmptyStateSuggestion,
} from '../emptyStateSuggestions';
import type { InstanceAiPrefillDeclaration } from '../prefills';
const props = defineProps<{
suggestions: readonly InstanceAiEmptyStateSuggestion[];
disabled: boolean;
}>();
interface InsertSuggestionPayload {
interface InsertSuggestionPayload extends InstanceAiPrefillDeclaration {
promptKey: BaseTextKey;
suggestionId: string;
suggestionKind: 'prompt' | 'quick_example';
@ -147,6 +148,7 @@ function handleSuggestionClick(suggestion: InstanceAiEmptyStateSuggestion) {
suggestionId: suggestion.id,
suggestionKind: 'prompt',
position: getTopLevelPosition(suggestion.id),
prefillType: 'v1_opener',
});
return;
}
@ -256,6 +258,7 @@ function handleQuickExampleLeave() {
suggestionId: example.id,
suggestionKind: 'quick_example',
position: getQuickExamplePosition(example.id),
prefillType: 'v1_opener',
})
"
@mouseenter="handleQuickExampleEnter(example.promptKey)"

View File

@ -187,12 +187,11 @@ const instanceAiCapability: InstanceAiEditorCapability = {
// The handoff context carries the recipe's verified key page and the
// paste-only steering; without it the agent re-researches or suggests
// editing the pre-filled form.
void thread.sendMessage(
buildInstanceAiArtifactCredentialQuestion(credential),
undefined,
rootStore.pushRef,
buildInstanceAiCredentialHandoffContext(credential),
);
void thread.sendMessage(buildInstanceAiArtifactCredentialQuestion(credential), {
authorship: { kind: 'prefill', prefillType: 'handoff_credential_setup' },
pushRef: rootStore.pushRef,
handoffContext: buildInstanceAiCredentialHandoffContext(credential),
});
// Appends to the current thread → close the modal so the conversation shows.
return true;
},

View File

@ -73,6 +73,7 @@ function expectCredentialThread(projectId: string) {
expect(mocks.startThread).toHaveBeenCalledWith(
projectId,
'credential question',
{ kind: 'prefill', prefillType: 'handoff_credential_setup' },
{ source: 'credential_edit', origin: 'internal' },
undefined,
undefined,

View File

@ -5,6 +5,7 @@ import { useInstanceAiAvailable } from './useInstanceAiAvailability';
import {
buildInstanceAiAgentPreviewHandoffContext,
useInstanceAiHandoff,
type PendingComposerDraft,
} from './useInstanceAiHandoff';
export interface AgentPreviewHandoffParams {
@ -15,7 +16,7 @@ export interface AgentPreviewHandoffParams {
agentIcon?: string;
sessionTitle?: string;
executionId?: string;
initialDraft?: string;
initialDraft?: PendingComposerDraft;
}
export function useInstanceAiAgentPreviewHandoff() {

View File

@ -46,6 +46,7 @@ export function useInstanceAiCredentialHelp(
await startThread(
projectId,
buildInstanceAiCredentialQuestion(subject),
{ kind: 'prefill', prefillType: 'handoff_credential_setup' },
{ source: options.source ?? 'credentials_list', origin: 'internal' },
undefined,
undefined,

View File

@ -28,6 +28,13 @@ import {
} from '../constants';
import { useInstanceAiStore } from '../instanceAi.store';
import { useInstanceAiReady } from './useInstanceAiAvailability';
import {
INSTANCE_AI_PREFILL_TYPE_FALLBACK,
isInstanceAiPrefillTypeReported,
isMessageAuthorship,
type InstanceAiMessageAuthorship,
type InstanceAiPrefillTypeReported,
} from '../prefills';
/** The existing credential id, when known, so the agent can act on it directly. */
function existingCredentialNote(credential: InstanceAiCredentialContext): string {
@ -86,6 +93,12 @@ export interface PendingFirstMessage {
message: string;
attachments?: InstanceAiResourceAttachment[];
context?: InstanceAiHandoffContext;
/**
* Required so a new hand-off cannot stash an opener that reports as
* user-typed. Optional on the read path only, for stashes a previous
* deploy wrote — see `consumePendingFirstMessage`.
*/
authorship: InstanceAiMessageAuthorship;
}
export function buildInstanceAiCredentialHandoffContext(
@ -153,7 +166,20 @@ export function consumePendingFirstMessage(threadId: string): PendingFirstMessag
if (!raw) return null;
localStorage.removeItem(pendingFirstMessageKey(threadId));
try {
return JSON.parse(raw) as PendingFirstMessage;
const parsed = JSON.parse(raw) as Partial<PendingFirstMessage>;
if (typeof parsed?.message !== 'string') return null;
return {
...parsed,
message: parsed.message,
// A stash written before openers were typed still has to replay: dropping it
// would lose a message the user sent from another tab across a deploy. Every
// stash comes from a hand-off, so an absent authorship is a pre-fill of an
// unrecoverable type -- reporting it as user-typed would be the exact
// misclassification the type exists to prevent.
authorship: isMessageAuthorship(parsed.authorship)
? parsed.authorship
: { kind: 'prefill', prefillType: INSTANCE_AI_PREFILL_TYPE_FALLBACK },
};
} catch {
return null;
}
@ -181,14 +207,45 @@ export function clearPendingHandoffContext(threadId: string): void {
localStorage.removeItem(pendingHandoffContextKey(threadId));
}
export function stashPendingComposerDraft(threadId: string, draft: string): void {
localStorage.setItem(pendingComposerDraftKey(threadId), draft);
export interface PendingComposerDraft {
text: string;
prefillType: InstanceAiPrefillTypeReported;
}
export function getPendingComposerDraft(threadId: string): string | null {
const draft = localStorage.getItem(pendingComposerDraftKey(threadId));
if (!draft) return null;
return draft;
export function stashPendingComposerDraft(threadId: string, draft: PendingComposerDraft): void {
localStorage.setItem(pendingComposerDraftKey(threadId), JSON.stringify(draft));
}
/**
* A draft is text the user is about to send, so it is never dropped for being
* unreadable: anything that is not a recognisable envelope is treated as the
* bare string a previous deploy stashed. Both surfaces that stash a draft are
* hand-offs, so naming either would mis-attribute the other -- keep the text
* and report the fallback type.
*/
export function getPendingComposerDraft(threadId: string): PendingComposerDraft | null {
const raw = localStorage.getItem(pendingComposerDraftKey(threadId));
if (!raw) return null;
const legacy: PendingComposerDraft = {
text: raw,
prefillType: INSTANCE_AI_PREFILL_TYPE_FALLBACK,
};
try {
const parsed = JSON.parse(raw) as Partial<PendingComposerDraft>;
// Only the type falls back. Rejecting the whole envelope over an
// unrecognised type would put the raw JSON in the composer for the user to
// send. The guard is the reported one, not the declarable one, so a draft
// stashed under the fallback round-trips.
if (typeof parsed?.text !== 'string') return legacy;
return {
text: parsed.text,
prefillType: isInstanceAiPrefillTypeReported(parsed.prefillType)
? parsed.prefillType
: INSTANCE_AI_PREFILL_TYPE_FALLBACK,
};
} catch {
return legacy;
}
}
export function clearPendingComposerDraft(threadId: string): void {
@ -296,7 +353,7 @@ export async function provisionContextOnlyThread(
projectId: string,
context: InstanceAiHandoffContext,
launch: InstanceAiThreadLaunch,
initialDraft?: string,
initialDraft?: PendingComposerDraft,
): Promise<string | null> {
const threadId = uuidv4();
try {
@ -347,7 +404,7 @@ export function useInstanceAiHandoff() {
launch: InstanceAiThreadLaunch,
options?: {
context?: InstanceAiHandoffContext;
initialDraft?: string;
initialDraft?: PendingComposerDraft;
},
): Promise<boolean> {
if (!instanceAiReady.value) {
@ -412,7 +469,7 @@ export function useInstanceAiHandoff() {
launch: InstanceAiThreadLaunch,
options?: {
newTab?: boolean;
initialDraft?: string;
initialDraft?: PendingComposerDraft;
},
): Promise<boolean> {
if (!instanceAiReady.value) {
@ -449,6 +506,7 @@ export function useInstanceAiHandoff() {
async function startThread(
projectId: string,
message: string,
authorship: InstanceAiMessageAuthorship,
launch: InstanceAiThreadLaunch,
attachments?: InstanceAiResourceAttachment[],
prepare?: (threadId: string) => void,
@ -472,7 +530,7 @@ export function useInstanceAiHandoff() {
const tab = window.open('', '_blank');
const threadId = await provisionLaunchedThread(
projectId,
{ message, attachments, context: options?.context },
{ message, attachments, context: options?.context, authorship },
launch,
);
if (!threadId) {
@ -495,7 +553,12 @@ export function useInstanceAiHandoff() {
}
const thread = instanceAiStore.getOrCreateRuntime(threadId, projectId);
prepare?.(threadId);
void thread.sendMessage(message, attachments, rootStore.pushRef, options?.context);
void thread.sendMessage(message, {
authorship,
attachments,
pushRef: rootStore.pushRef,
handoffContext: options?.context,
});
await router.push({ name: INSTANCE_AI_THREAD_VIEW, params: { threadId } });
} finally {
handoffInFlight = false;
@ -531,7 +594,11 @@ export function useInstanceAiHandoff() {
};
// Empty message → the editor-context block just greets; the attachment
// opens the canvas preview via the thread view's firstAttachedArtifactId.
stashPendingFirstMessage(threadId, { message: '', attachments: [attachment] });
stashPendingFirstMessage(threadId, {
message: '',
attachments: [attachment],
authorship: { kind: 'prefill', prefillType: 'workflow_attachment_opener' },
});
if (workflow.snapshot) {
instanceAiStore
.getOrCreateRuntime(threadId, projectId)

View File

@ -26,6 +26,7 @@ import {
buildInstanceAiCredentialQuestion,
useInstanceAiHandoff,
} from './useInstanceAiHandoff';
import type { InstanceAiMessageAuthorship } from '../prefills';
/**
* The standalone editor's `InstanceAiEditorCapability` (behavior; visibility is the
@ -117,6 +118,11 @@ export function useInstanceAiHandoffCapability(): InstanceAiEditorCapability {
(executionFailed
? 'The execution failed. Look into what went wrong and help me fix it.'
: '');
// Derived next to the text it describes, so the two cannot drift.
const authorship: InstanceAiMessageAuthorship = {
kind: 'prefill',
prefillType: executionFailed ? 'handoff_execution_error' : 'workflow_attachment_opener',
};
// Close any open NDV before navigating. Otherwise its children unmount during
// the route change — after the workflow document store is gone — and throw via
// injectNDVStore(), aborting the navigation and leaving a blank screen. No-op
@ -129,6 +135,7 @@ export function useInstanceAiHandoffCapability(): InstanceAiEditorCapability {
await startThread(
projectId,
openingMessage,
authorship,
{
source,
origin: 'internal',
@ -195,10 +202,18 @@ export function useInstanceAiHandoffCapability(): InstanceAiEditorCapability {
});
return false;
}
await startThread(projectId, question, { source, origin: 'internal' }, undefined, undefined, {
newTab: true,
context: buildInstanceAiCredentialHandoffContext(credential),
});
await startThread(
projectId,
question,
{ kind: 'prefill', prefillType: 'handoff_credential_setup' },
{ source, origin: 'internal' },
undefined,
undefined,
{
newTab: true,
context: buildInstanceAiCredentialHandoffContext(credential),
},
);
telemetry.track('Instance AI opened from editor', {
source,
workflow_id: null,

View File

@ -6,6 +6,7 @@ import type {
InstanceAiHandoffContext,
InstanceAiSetupItem,
} from '@n8n/api-types';
import type { InstanceAiMessageAuthorship } from '../prefills';
import { useI18n } from '@n8n/i18n';
import { ResponseError } from '@n8n/rest-api-client';
import { useRootStore } from '@n8n/stores/useRootStore';
@ -59,9 +60,12 @@ export type SetupPanelApplyResult =
export interface SetupPanelThreadActions {
sendMessage: (
message: string,
attachments?: InstanceAiAttachment[],
pushRef?: string,
handoffContext?: InstanceAiHandoffContext,
opts: {
authorship: InstanceAiMessageAuthorship;
attachments?: InstanceAiAttachment[];
pushRef?: string;
handoffContext?: InstanceAiHandoffContext;
},
) => Promise<boolean>;
}
@ -453,12 +457,11 @@ export function useSetupPanelActions(options: {
async function executeWorkflow(): Promise<boolean> {
const workflowId = toValue(options.workflowId);
if (!workflowId) return false;
return await options.thread.sendMessage(
i18n.baseText('instanceAi.setupPanel.executeMessage'),
undefined,
rootStore.pushRef,
{ source: 'setup-panel-execute', workflowId },
);
return await options.thread.sendMessage(i18n.baseText('instanceAi.setupPanel.executeMessage'), {
authorship: { kind: 'prefill', prefillType: 'handoff_setup_panel_execute' },
pushRef: rootStore.pushRef,
handoffContext: { source: 'setup-panel-execute', workflowId },
});
}
return {

View File

@ -27,7 +27,7 @@ import {
} from '@n8n/api-types';
import { useRootStore } from '@n8n/stores/useRootStore';
import { useInstanceAiSettingsStore } from './instanceAiSettings.store';
import { redactTelemetryProperties } from '@n8n/telemetry';
import { redactTelemetryProperties, TELEMETRY_EVENT } from '@n8n/telemetry';
import { useToast } from '@n8n/composables/useToast';
import { useI18n } from '@n8n/i18n';
import { useTelemetry } from '@n8n/composables/useTelemetry';
@ -45,6 +45,7 @@ import {
fetchThreadMessages as fetchThreadMessagesApi,
fetchThreadStatus as fetchThreadStatusApi,
} from './instanceAi.memory.api';
import type { InstanceAiMessageAuthorship } from './prefills';
import { handleEvent as reduceEvent, createRunStateFromTree } from './instanceAi.reducer';
import { getLatestBuildResult, type RememberedManualExecution } from './canvasPreview.utils';
import { useResourceRegistry } from './useResourceRegistry';
@ -1228,7 +1229,10 @@ export function createThreadRuntime(
}
}
function trackUserMessageSent(isFirstMessage: boolean): void {
function trackUserMessageSent(
isFirstMessage: boolean,
authorship: InstanceAiMessageAuthorship,
): void {
const rawSource = hooks.getThreadMetadata?.(threadId)?.source;
const actionSource = isInstanceAiThreadSource(rawSource)
? rawSource
@ -1245,11 +1249,17 @@ export function createThreadRuntime(
);
}
telemetry.track('User sent builder message', {
const isPrefill = authorship.kind === 'prefill';
telemetry.track(TELEMETRY_EVENT.INSTANCE_AI.USER_SENT_BUILDER_MESSAGE, {
thread_id: threadId,
instance_id: rootStore.instanceId,
is_first_message: isFirstMessage,
action_source: actionSource,
// Explicit nulls, not omissions: the warehouse needs the column present on
// organic messages so `prefill_type IS NULL` is a usable predicate.
prefill_type: isPrefill ? authorship.prefillType : null,
prefill_id: isPrefill ? (authorship.prefillId ?? null) : null,
prompt_modified: isPrefill ? (authorship.promptModified ?? false) : null,
});
}
@ -1308,19 +1318,27 @@ export function createThreadRuntime(
}
}
/**
* `authorship` is required so a new pre-fill surface cannot ship untagged:
* omitting it fails typecheck rather than reporting the opener as user-typed.
*/
async function sendMessage(
message: string,
attachments?: InstanceAiAttachment[],
pushRef?: string,
handoffContext?: InstanceAiHandoffContext,
opts: {
authorship: InstanceAiMessageAuthorship;
attachments?: InstanceAiAttachment[];
pushRef?: string;
handoffContext?: InstanceAiHandoffContext;
},
): Promise<boolean> {
const { authorship, attachments, pushRef, handoffContext } = opts;
amendContext.value = null;
pendingMessageCount.value += 1;
try {
ensureSSEConnected();
const isFirstMessage = !messages.value.some((m) => m.role === 'user');
const optimistic = pushOptimisticUserMessage(message, attachments, handoffContext);
trackUserMessageSent(isFirstMessage);
trackUserMessageSent(isFirstMessage, authorship);
if (!(await dispatchUserMessage(message, attachments, handoffContext, pushRef))) {
removeOptimisticMessage(optimistic);

View File

@ -105,6 +105,7 @@ export const InstanceAiModule = defineFrontendModule({
message: i18n.baseText('instanceAi.launch.templateById.message', {
interpolate: { id: templateId },
}),
authorship: { kind: 'prefill', prefillType: 'template_adjustment' },
},
{ source: 'website-template', origin: 'external', sourceContext: { templateId } },
);

View File

@ -0,0 +1,91 @@
import {
INSTANCE_AI_PREFILL_TYPES,
INSTANCE_AI_PREFILL_TYPE_FALLBACK,
type InstanceAiPrefillType,
type InstanceAiPrefillTypeReported,
} from '@n8n/api-types';
/**
* Editor-side plumbing for the pre-fill taxonomy. The list of types lives in
* `@n8n/api-types` (`INSTANCE_AI_PREFILL_TYPES`), beside the thread-source
* taxonomy and the request schemas that enforce it; this module holds the
* pieces only the editor needs and re-exports the vocabulary so call sites have
* one import.
*
* A pre-fill is message text n8n wrote, not text the user typed. It is reported
* on `User sent builder message` so analytics no longer has to recover the type
* by string-matching the message body.
*
* Nothing can report an untagged pre-fill: `sendMessage` and
* `PendingFirstMessage` require an authorship, suggestion catalogs intersect
* their insert payload with `InstanceAiPrefillDeclaration`, and pre-filled text
* reaches the composer through `setPrefill` rather than `setText`.
*/
export {
INSTANCE_AI_PREFILL_TYPES,
INSTANCE_AI_PREFILL_TYPE_FALLBACK,
type InstanceAiPrefillType,
type InstanceAiPrefillTypeReported,
};
/** Who wrote the message being sent. Required at every send boundary. */
export type InstanceAiMessageAuthorship =
| { kind: 'user_typed' }
| {
kind: 'prefill';
prefillType: InstanceAiPrefillTypeReported;
/** Catalog entry id, for types that have sub-items. */
prefillId?: string;
/**
* Composer pre-fills only. Auto-sent text is never shown before it
* goes, so the user cannot have edited it, and the one composer that
* can be edited derives this centrally.
*/
promptModified?: boolean;
};
/**
* Every suggestion catalog intersects its own insert payload with this, so a
* new catalog that forgets to say which pre-fill type its entries are fails
* typecheck at the emit rather than reporting them as user-typed.
*/
export type InstanceAiPrefillDeclaration = { prefillType: InstanceAiPrefillType };
export const USER_TYPED_MESSAGE: InstanceAiMessageAuthorship = { kind: 'user_typed' };
const INSTANCE_AI_PREFILL_TYPE_SET: ReadonlySet<string> = new Set(INSTANCE_AI_PREFILL_TYPES);
export function isInstanceAiPrefillType(value: unknown): value is InstanceAiPrefillType {
return typeof value === 'string' && INSTANCE_AI_PREFILL_TYPE_SET.has(value);
}
/**
* Accepts the read-path fallback as well, for values coming back out of storage.
* Surfaces declare `InstanceAiPrefillType` and so cannot reach for the fallback.
*/
export function isInstanceAiPrefillTypeReported(
value: unknown,
): value is InstanceAiPrefillTypeReported {
return isInstanceAiPrefillType(value) || value === INSTANCE_AI_PREFILL_TYPE_FALLBACK;
}
/**
* Validates an authorship read back from storage. Everything that reaches
* telemetry is checked, not just the discriminant: an unrecognised type would
* widen the reported enum, and a non-string id or non-boolean flag would break
* the event's schema.
*/
export function isMessageAuthorship(value: unknown): value is InstanceAiMessageAuthorship {
if (typeof value !== 'object' || value === null) return false;
const candidate = value as {
kind?: unknown;
prefillType?: unknown;
prefillId?: unknown;
promptModified?: unknown;
};
if (candidate.kind === 'user_typed') return true;
if (candidate.kind !== 'prefill') return false;
if (!isInstanceAiPrefillTypeReported(candidate.prefillType)) return false;
if (candidate.prefillId !== undefined && typeof candidate.prefillId !== 'string') return false;
return candidate.promptModified === undefined || typeof candidate.promptModified === 'boolean';
}

View File

@ -444,6 +444,7 @@ describe('WorkflowSetupSectionBody', () => {
expect(instanceAiHandoffMock.startThread).toHaveBeenCalledWith(
'project-1',
'Where do I find the "fal.ai API key" for my "fal.ai API Key" credential?',
{ kind: 'prefill', prefillType: 'handoff_credential_setup' },
{ source: 'credential_edit', origin: 'internal' },
undefined,
undefined,

View File

@ -69,6 +69,7 @@ const startWithAi = async () => {
i18n.baseText('instanceAi.launch.template.message', {
interpolate: { name: template.value.name, id: templateId.value },
}),
{ kind: 'prefill', prefillType: 'template_adjustment' },
{
source: 'template-view',
origin: 'internal',

View File

@ -3846,6 +3846,9 @@ importers:
packages/@n8n/telemetry:
dependencies:
'@n8n/api-types':
specifier: workspace:*
version: link:../api-types
'@n8n/utils':
specifier: workspace:*
version: link:../utils