diff --git a/packages/@n8n/api-types/src/index.ts b/packages/@n8n/api-types/src/index.ts index dd7d38b3c3c..56ef7543dcb 100644 --- a/packages/@n8n/api-types/src/index.ts +++ b/packages/@n8n/api-types/src/index.ts @@ -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, diff --git a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts index 24c011da89c..d74d5b354dd 100644 --- a/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts +++ b/packages/@n8n/api-types/src/schemas/instance-ai.schema.ts @@ -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]; diff --git a/packages/@n8n/telemetry/package.json b/packages/@n8n/telemetry/package.json index 22fdb26c67e..6ea21bb6561 100644 --- a/packages/@n8n/telemetry/package.json +++ b/packages/@n8n/telemetry/package.json @@ -23,6 +23,7 @@ "dist/**/*" ], "dependencies": { + "@n8n/api-types": "workspace:*", "@n8n/utils": "workspace:*", "zod": "catalog:" }, diff --git a/packages/@n8n/telemetry/src/events/instance-ai.ts b/packages/@n8n/telemetry/src/events/instance-ai.ts index ea19e91278c..042f7640d63 100644 --- a/packages/@n8n/telemetry/src/events/instance-ai.ts +++ b/packages/@n8n/telemetry/src/events/instance-ai.ts @@ -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: diff --git a/packages/frontend/editor-ui/src/experiments/instanceAiPersonalizedPromptSuggestions/components/InstanceAiPersonalizedPromptSuggestions.test.ts b/packages/frontend/editor-ui/src/experiments/instanceAiPersonalizedPromptSuggestions/components/InstanceAiPersonalizedPromptSuggestions.test.ts index 2f32402fb2a..58b17aec566 100644 --- a/packages/frontend/editor-ui/src/experiments/instanceAiPersonalizedPromptSuggestions/components/InstanceAiPersonalizedPromptSuggestions.test.ts +++ b/packages/frontend/editor-ui/src/experiments/instanceAiPersonalizedPromptSuggestions/components/InstanceAiPersonalizedPromptSuggestions.test.ts @@ -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' }, }, diff --git a/packages/frontend/editor-ui/src/experiments/instanceAiPersonalizedPromptSuggestions/components/InstanceAiPersonalizedPromptSuggestions.vue b/packages/frontend/editor-ui/src/experiments/instanceAiPersonalizedPromptSuggestions/components/InstanceAiPersonalizedPromptSuggestions.vue index 63075bb5b37..c763b44cefd 100644 --- a/packages/frontend/editor-ui/src/experiments/instanceAiPersonalizedPromptSuggestions/components/InstanceAiPersonalizedPromptSuggestions.vue +++ b/packages/frontend/editor-ui/src/experiments/instanceAiPersonalizedPromptSuggestions/components/InstanceAiPersonalizedPromptSuggestions.vue @@ -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', diff --git a/packages/frontend/editor-ui/src/experiments/instanceAiPromptSuggestionsV2/components/InstanceAiPromptSuggestionsV2.test.ts b/packages/frontend/editor-ui/src/experiments/instanceAiPromptSuggestionsV2/components/InstanceAiPromptSuggestionsV2.test.ts index df469c256a4..e0f412be407 100644 --- a/packages/frontend/editor-ui/src/experiments/instanceAiPromptSuggestionsV2/components/InstanceAiPromptSuggestionsV2.test.ts +++ b/packages/frontend/editor-ui/src/experiments/instanceAiPromptSuggestionsV2/components/InstanceAiPromptSuggestionsV2.test.ts @@ -231,6 +231,7 @@ describe('InstanceAiPromptSuggestionsV2', () => { promptKey: 'instanceAi.emptyState.quickExamples.answerSupportRequests.prompt', suggestionId: 'answer-support-requests', suggestionKind: 'prompt', + prefillType: 'suggestion_catalog', position: 6, }, ], diff --git a/packages/frontend/editor-ui/src/experiments/instanceAiPromptSuggestionsV2/components/InstanceAiPromptSuggestionsV2.vue b/packages/frontend/editor-ui/src/experiments/instanceAiPromptSuggestionsV2/components/InstanceAiPromptSuggestionsV2.vue index 69db24e6c8e..7dae10812e1 100644 --- a/packages/frontend/editor-ui/src/experiments/instanceAiPromptSuggestionsV2/components/InstanceAiPromptSuggestionsV2.vue +++ b/packages/frontend/editor-ui/src/experiments/instanceAiPromptSuggestionsV2/components/InstanceAiPromptSuggestionsV2.vue @@ -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', }); } diff --git a/packages/frontend/editor-ui/src/experiments/instanceAiSplitEmptyState/components/SuggestionList.test.ts b/packages/frontend/editor-ui/src/experiments/instanceAiSplitEmptyState/components/SuggestionList.test.ts index d9a648d6bce..367011e62b9 100644 --- a/packages/frontend/editor-ui/src/experiments/instanceAiSplitEmptyState/components/SuggestionList.test.ts +++ b/packages/frontend/editor-ui/src/experiments/instanceAiSplitEmptyState/components/SuggestionList.test.ts @@ -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, }, ], diff --git a/packages/frontend/editor-ui/src/experiments/instanceAiSplitEmptyState/components/SuggestionList.vue b/packages/frontend/editor-ui/src/experiments/instanceAiSplitEmptyState/components/SuggestionList.vue index c9c4a01d77f..6b161a049c8 100644 --- a/packages/frontend/editor-ui/src/experiments/instanceAiSplitEmptyState/components/SuggestionList.vue +++ b/packages/frontend/editor-ui/src/experiments/instanceAiSplitEmptyState/components/SuggestionList.vue @@ -48,6 +48,7 @@ function buildPayload( suggestionId: example.id, suggestionKind: 'quick_example', position: index + 1, + prefillType: 'suggestion_catalog', }; } diff --git a/packages/frontend/editor-ui/src/experiments/instanceAiSplitEmptyState/examples.ts b/packages/frontend/editor-ui/src/experiments/instanceAiSplitEmptyState/examples.ts index fa33cb5aff8..3387a90f6ed 100644 --- a/packages/frontend/editor-ui/src/experiments/instanceAiSplitEmptyState/examples.ts +++ b/packages/frontend/editor-ui/src/experiments/instanceAiSplitEmptyState/examples.ts @@ -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'; diff --git a/packages/frontend/editor-ui/src/experiments/instanceAiWorkflowPreviewSuggestions/components/WorkflowPreviewSuggestions.test.ts b/packages/frontend/editor-ui/src/experiments/instanceAiWorkflowPreviewSuggestions/components/WorkflowPreviewSuggestions.test.ts index 4500da72fab..c5b1ff156c5 100644 --- a/packages/frontend/editor-ui/src/experiments/instanceAiWorkflowPreviewSuggestions/components/WorkflowPreviewSuggestions.test.ts +++ b/packages/frontend/editor-ui/src/experiments/instanceAiWorkflowPreviewSuggestions/components/WorkflowPreviewSuggestions.test.ts @@ -42,6 +42,7 @@ describe('WorkflowPreviewSuggestions', () => { promptKey: suggestion.promptKey, suggestionId: suggestion.id, suggestionKind: 'prompt', + prefillType: 'suggestion_catalog', position: 2, }, ], diff --git a/packages/frontend/editor-ui/src/experiments/instanceAiWorkflowPreviewSuggestions/components/WorkflowPreviewSuggestions.vue b/packages/frontend/editor-ui/src/experiments/instanceAiWorkflowPreviewSuggestions/components/WorkflowPreviewSuggestions.vue index 6f399190435..ea660eacecf 100644 --- a/packages/frontend/editor-ui/src/experiments/instanceAiWorkflowPreviewSuggestions/components/WorkflowPreviewSuggestions.vue +++ b/packages/frontend/editor-ui/src/experiments/instanceAiWorkflowPreviewSuggestions/components/WorkflowPreviewSuggestions.vue @@ -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', }); } diff --git a/packages/frontend/editor-ui/src/experiments/openWorkflowInAssistant/launchWorkflowThread.test.ts b/packages/frontend/editor-ui/src/experiments/openWorkflowInAssistant/launchWorkflowThread.test.ts index a36f6938afc..794cad19128 100644 --- a/packages/frontend/editor-ui/src/experiments/openWorkflowInAssistant/launchWorkflowThread.test.ts +++ b/packages/frontend/editor-ui/src/experiments/openWorkflowInAssistant/launchWorkflowThread.test.ts @@ -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', diff --git a/packages/frontend/editor-ui/src/experiments/openWorkflowInAssistant/launchWorkflowThread.ts b/packages/frontend/editor-ui/src/experiments/openWorkflowInAssistant/launchWorkflowThread.ts index 4106e5d394d..459e3c6636d 100644 --- a/packages/frontend/editor-ui/src/experiments/openWorkflowInAssistant/launchWorkflowThread.ts +++ b/packages/frontend/editor-ui/src/experiments/openWorkflowInAssistant/launchWorkflowThread.ts @@ -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; diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts index 4d1d6800be9..63983ac870c 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderView.test.ts @@ -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', + }, }), ], ]); diff --git a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue index efe931abd22..5f009077ae0 100644 --- a/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue +++ b/packages/frontend/editor-ui/src/features/agents/views/AgentBuilderView.vue @@ -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) { diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiEmptyView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiEmptyView.vue index 02db72d20ad..19de6d3fb8b 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiEmptyView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiEmptyView.vue @@ -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(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); } diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue index 362fd0c497b..3174993f662 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/InstanceAiThreadView.vue @@ -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(null); -const pendingComposerDraft = ref(null); +const pendingComposerDraft = ref(null); const generatedComposerDraft = ref(null); const pendingAgentAttachment = ref(null); const currentAgentAttachment = computed(() => { @@ -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; diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiCredentialSetup.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiCredentialSetup.test.ts index 4901d6a368a..2dbaf270c81 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiCredentialSetup.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiCredentialSetup.test.ts @@ -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, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiEmptyView.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiEmptyView.test.ts index 7cc38ccd0d8..0c6519942f5 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiEmptyView.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiEmptyView.test.ts @@ -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', + }, + ); + }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiInput.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiInput.test.ts index cade6a29861..cae67e0b63b 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiInput.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiInput.test.ts @@ -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 | null>(null); + return () => + h('div', [ + h(InstanceAiInput, { + ref: (el: unknown) => { + inputRef.value = el as InstanceType | 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' }], ]); }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiPromptSuggestions.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiPromptSuggestions.test.ts index 820a0d480cd..656052b72b4 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiPromptSuggestions.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiPromptSuggestions.test.ts @@ -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', }, ], ]); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiThreadView.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiThreadView.test.ts index 5b17d62b8d0..40257546c05 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiThreadView.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/InstanceAiThreadView.test.ts @@ -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(); }); }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.threadRuntime.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.threadRuntime.test.ts index a087d918486..5abf225e54b 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.threadRuntime.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/instanceAi.threadRuntime.test.ts @@ -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(); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/prefills.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/prefills.test.ts new file mode 100644 index 00000000000..e1423d377a7 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/prefills.test.ts @@ -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(); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useInstanceAiAgentPreviewHandoff.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useInstanceAiAgentPreviewHandoff.test.ts index f22b2d79cf6..4626e9219c0 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useInstanceAiAgentPreviewHandoff.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useInstanceAiAgentPreviewHandoff.test.ts @@ -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( diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useInstanceAiHandoff.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useInstanceAiHandoff.test.ts index 4a04192304f..63f120dd076 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useInstanceAiHandoff.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useInstanceAiHandoff.test.ts @@ -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(); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useSetupPanelActions.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useSetupPanelActions.test.ts index 0a34bda12a0..5a14af09fc6 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useSetupPanelActions.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/__tests__/useSetupPanelActions.test.ts @@ -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 () => { diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiInput.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiInput.vue index 92395674382..f2e9ba18a33 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiInput.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiInput.vue @@ -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 | null>(null); // Experiment cleanup: remove with instanceAiPromptSuggestionsV2. const previewPrompt = ref(null); const selectedSuggestionDraft = ref(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(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(); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiPromptSuggestions.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiPromptSuggestions.vue index fc60e43fada..6ef55fa82a0 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiPromptSuggestions.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiPromptSuggestions.vue @@ -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)" diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiWorkflowPreview.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiWorkflowPreview.vue index 793d27b7295..7b1af01549a 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiWorkflowPreview.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/InstanceAiWorkflowPreview.vue @@ -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; }, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/__tests__/useInstanceAiHandoffCapability.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/__tests__/useInstanceAiHandoffCapability.test.ts index 245f7f09d68..b212e55bb1b 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/__tests__/useInstanceAiHandoffCapability.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/__tests__/useInstanceAiHandoffCapability.test.ts @@ -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, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiAgentPreviewHandoff.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiAgentPreviewHandoff.ts index 4118366489c..186953d69c2 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiAgentPreviewHandoff.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiAgentPreviewHandoff.ts @@ -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() { diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiCredentialHelp.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiCredentialHelp.ts index 4ae1a8325e3..50565d78c53 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiCredentialHelp.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiCredentialHelp.ts @@ -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, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiHandoff.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiHandoff.ts index 56515a00ea0..7890c2ce892 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiHandoff.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiHandoff.ts @@ -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; + 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; + // 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 { const threadId = uuidv4(); try { @@ -347,7 +404,7 @@ export function useInstanceAiHandoff() { launch: InstanceAiThreadLaunch, options?: { context?: InstanceAiHandoffContext; - initialDraft?: string; + initialDraft?: PendingComposerDraft; }, ): Promise { if (!instanceAiReady.value) { @@ -412,7 +469,7 @@ export function useInstanceAiHandoff() { launch: InstanceAiThreadLaunch, options?: { newTab?: boolean; - initialDraft?: string; + initialDraft?: PendingComposerDraft; }, ): Promise { 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) diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiHandoffCapability.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiHandoffCapability.ts index bdd69c7ac22..dc3201426ad 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiHandoffCapability.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useInstanceAiHandoffCapability.ts @@ -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, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useSetupPanelActions.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useSetupPanelActions.ts index 6e1e9a15529..6cf46711e6d 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useSetupPanelActions.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/composables/useSetupPanelActions.ts @@ -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; } @@ -453,12 +457,11 @@ export function useSetupPanelActions(options: { async function executeWorkflow(): Promise { 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 { diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.threadRuntime.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.threadRuntime.ts index dc8d22acf6a..29dc3e2f782 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.threadRuntime.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAi.threadRuntime.ts @@ -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 { + 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); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts index 623a23e6948..ee3862f8e75 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/module.descriptor.ts @@ -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 } }, ); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/prefills.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/prefills.ts new file mode 100644 index 00000000000..ef023ffd2a1 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/prefills.ts @@ -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 = 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'; +} diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/components/WorkflowSetupSectionBody.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/components/WorkflowSetupSectionBody.test.ts index 518c9cb519b..e67d3412b67 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/components/WorkflowSetupSectionBody.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/workflowSetup/components/WorkflowSetupSectionBody.test.ts @@ -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, diff --git a/packages/frontend/editor-ui/src/features/workflows/templates/views/TemplatesWorkflowView.vue b/packages/frontend/editor-ui/src/features/workflows/templates/views/TemplatesWorkflowView.vue index 8c379670b2d..85a667dc146 100644 --- a/packages/frontend/editor-ui/src/features/workflows/templates/views/TemplatesWorkflowView.vue +++ b/packages/frontend/editor-ui/src/features/workflows/templates/views/TemplatesWorkflowView.vue @@ -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', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb982a7c4a2..32dd1371c50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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