test(editor): Cover linked automation proposals end to end (no-changelog) (#40835)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Swifty <Swiftyos@users.noreply.github.com>
This commit is contained in:
Swifty 2026-10-11 09:06:23 +02:00 committed by GitHub
parent a29191a37c
commit da90c0b3a3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 1961 additions and 84 deletions

View File

@ -224,7 +224,7 @@ jobs:
if: fromJSON(steps.ci-filter.outputs.results).ci || fromJSON(steps.ci-filter.outputs.results).e2e
uses: ./.github/actions/setup-nodejs
with:
build-command: ${{ fromJSON(steps.ci-filter.outputs.results).ci && 'pnpm build' || 'pnpm turbo run build --filter=@n8n/playwright-janitor --filter=@n8n/workflow-sdk...' }}
build-command: ${{ fromJSON(steps.ci-filter.outputs.results).ci && 'pnpm build' || 'pnpm turbo run build --filter=@n8n/playwright-janitor --filter=@n8n/workflow-sdk... --filter=@n8n/api-types...' }}
- name: Report build critical path
if: fromJSON(steps.ci-filter.outputs.results).ci
@ -381,7 +381,8 @@ jobs:
- name: Setup Node.js
uses: ./.github/actions/setup-nodejs
with:
build-command: pnpm turbo run build --filter=@n8n/playwright-janitor --filter=@n8n/workflow-sdk...
# The test list imports experienceModeSchema from @n8n/api-types.
build-command: pnpm turbo run build --filter=@n8n/playwright-janitor --filter=@n8n/workflow-sdk... --filter=@n8n/api-types...
- name: Generate shard matrix
id: generate-matrix

View File

@ -48,7 +48,7 @@ jobs:
- name: Setup
uses: ./.github/actions/setup-nodejs
with:
build-command: pnpm turbo run build --filter=@n8n/playwright-janitor --filter=@n8n/workflow-sdk...
build-command: pnpm turbo run build --filter=@n8n/playwright-janitor --filter=@n8n/workflow-sdk... --filter=@n8n/api-types...
- name: Generate matrix (up to 20 shards, duration-weighted)
id: gen

View File

@ -72,7 +72,7 @@ jobs:
- name: Setup
uses: ./.github/actions/setup-nodejs
with:
build-command: pnpm turbo run build --filter=@n8n/playwright-janitor --filter=@n8n/workflow-sdk...
build-command: pnpm turbo run build --filter=@n8n/playwright-janitor --filter=@n8n/workflow-sdk... --filter=@n8n/api-types...
- name: Generate matrix (up to 20 shards, duration-weighted)
id: gen

View File

@ -0,0 +1,35 @@
---
name: code-mode
description: >-
Builds a website, landing page, or web app in code mode. Load when the user
asks to make, build, or design a website, site, landing page, web page, or
web app, or when they ask for code mode. Do not refuse these requests. Do
not route them to workflow-builder unless they also ask for an automation.
---
# Code mode
Use this skill for a website, landing page, or web app. The product opens
code mode for this request. The draft is a single page in a worktree named
`site/draft`.
## Do this
1. Say that code mode is open and name the page you are making.
2. Describe the page in three short parts: the headline, the sections, and
the next change you can make.
3. Ask one question only when a missing fact blocks the page. Otherwise
continue with a sensible draft.
4. Stay in code mode for follow-up edits to the copy, layout, or sections.
## Do not do this
- Do not say you cannot build a website.
- Do not send the request to `workflow-builder` or `intent-recognition`.
- Do not invent a hosting URL or claim the page is published.
## Promote
Offer a workflow only when the user also wants the page to run on a schedule,
from a form, or as a repeated job. Until then the page stays a draft in this
chat.

View File

@ -70,8 +70,11 @@ Two orthogonal decisions per request, or per part for compound requests:
questions (e.g. asking what the assistant is capable of building) and
one-off content tasks with no trigger, no persistence, and no reuse intent
(summarize, translate, or draft something once) — answer or do these
directly instead of building an automation. This bucket only applies when
you can actually do the task directly: a one-off question or report that
directly instead of building an automation. A request to make a website,
landing page, or web app is not out-of-scope. Load the `code-mode` skill.
Do not build a workflow unless the user also asks for an automation. This
bucket only applies when you can actually do the task directly: a one-off
question or report that
needs external systems you have no ad-hoc access to (a private issue
tracker, wiki, or CRM) is not out-of-scope — classify it, and when
answering requires judgment-driven navigation of those systems it is

View File

@ -0,0 +1,9 @@
import { getSystemPrompt } from '../system-prompt';
describe('system prompt code mode', () => {
it('tells the assistant to build a website in code mode', () => {
const prompt = getSystemPrompt({});
expect(prompt).toContain('code-mode');
expect(prompt).toContain('Do not say you cannot build a website');
});
});

View File

@ -279,6 +279,8 @@ When a capability the user asked for has no reliable path in n8n — no node/API
This is not a reason to add friction to feasible requests — when every requested capability is achievable, build it directly.
- **A website, landing page, or web app is in scope.** Load the \`code-mode\` skill and work in code mode. Do not say you cannot build a website. Do not send that request to workflow-builder unless the user also asks for an automation.
## Setup Accuracy
Don't fabricate provider setup mechanics (credential field names, secret values, OAuth scope strings, verification steps) you can't confirm from the node, the credential, or docs — if you can't verify it, say so instead of guessing.

View File

@ -751,7 +751,6 @@ async function handleResume(
throw error;
}
return await runBuilderConsumeLoop({
context,
delegate,
@ -1083,7 +1082,6 @@ export function createBuildAgentTool(context: OrchestrationContext) {
const activity: InstanceAiAgentActivity =
resolution.mode === 'create' ? 'creating' : (input.operation ?? 'working');
// Before the builder touches it: a repair-shaped eval case seeds from the
// state the turn opened on. A new agent has no prior state.
if (resolution.mode !== 'create') {

View File

@ -1,4 +1,3 @@
export type InstanceAiEventMap = {
'instance-ai-run-finished': {
/** 'suspended' is a non-terminal HITL segment: usage/tool counts only; the terminal event counts the run. */

View File

@ -6,7 +6,6 @@ import { PrometheusMetricsConfig } from '@n8n/config';
import { mock } from 'vitest-mock-extended';
import promClient from 'prom-client';
import { DURATION_BUCKETS_SECONDS } from '../constant';
import { PrometheusInstanceAiMetricsService } from '../instance-ai-metrics.service';
@ -87,7 +86,6 @@ describe('PrometheusInstanceAiMetricsService', () => {
help: 'Total estimated cost in USD of Instance AI runs (models.dev pricing).',
});
});
});
describe('instance-ai-run-finished event handler', () => {

View File

@ -3,7 +3,6 @@ import { PrometheusMetricsConfig } from '@n8n/config';
import { Service } from '@n8n/di';
import promClient from 'prom-client';
import type { PrometheusMetricsCollector } from './base';
import { DURATION_BUCKETS_SECONDS } from './constant';
@ -63,9 +62,6 @@ export class PrometheusInstanceAiMetricsService implements PrometheusMetricsColl
});
costTotal.inc(0);
this.eventService.on(
'instance-ai-run-finished',
({ status, durationMs, model, toolCalls, toolErrors, usage }) => {

View File

@ -180,6 +180,24 @@ describe('agent-sse-stream — stringifyError (via error chunk)', () => {
expect(events).toEqual([{ type: 'error', message: 'something broke' }]);
});
it('maps a quota-exhausted provider error to the assistant credit message', async () => {
const error = Object.assign(new Error('Forbidden'), {
responseBody: JSON.stringify({
message: 'Have reached end of quota',
code: 'quota_exhausted',
}),
});
const events = await collectEvents([{ type: 'error', error }]);
expect(events).toEqual([
{
type: 'error',
message:
"You've run out of AI credits. Upgrade your plan to continue using the n8n Assistant.",
errorCode: 'quota_exhausted',
},
]);
});
it('JSON-stringifies a plain object error', async () => {
const error = { code: 'TIMEOUT', retryAfter: 30 };
const events = await collectEvents([{ type: 'error', error }]);

View File

@ -1,4 +1,5 @@
import type { AgentMessage, StreamChunk } from '@n8n/agents';
import { isQuotaExhaustedError } from '@n8n/instance-ai';
import type {
AgentPersistedMessageContentPart,
AgentSseEvent,
@ -268,6 +269,17 @@ export function emitChunkEvents(
});
return;
case 'error': {
// The credit pool answers with a machine code. Show the same sentence the
// Assistant uses, so the chat can render the out-of-credits state.
if (isQuotaExhaustedError(chunk.error)) {
send({
type: 'error',
message:
"You've run out of AI credits. Upgrade your plan to continue using the n8n Assistant.",
errorCode: 'quota_exhausted',
});
return;
}
const errMsg = stringifyError(chunk.error);
send({ type: 'error', message: errMsg });
return;

View File

@ -105,6 +105,10 @@ export class SystemAgentExecutionService {
}
return existing;
}
// `register` inserts this row at boot. An end-to-end reset truncates `project`,
// and Postgres CASCADE removes the instance agent with it. Insert it again
// before the thread, or the agentId foreign key rejects the session.
await this.agentRepository.ensureInstanceAgent(provider.agentId, provider.name);
const { thread } = await this.txRunner.run(
{},
async (ctx) =>

View File

@ -15,10 +15,7 @@ vi.mock('@n8n/instance-ai', () => ({
submitLangsmithUserFeedback: (...args: unknown[]) => submitLangsmithUserFeedback(...args),
}));
import {
InstanceAiTracingService,
type InstanceAiTracingEventReader,
} from '../tracing';
import { InstanceAiTracingService, type InstanceAiTracingEventReader } from '../tracing';
type FakeTraceRun = {
id: string;
@ -283,5 +280,4 @@ describe('InstanceAiTracingService', () => {
expect(service.getTraceContext('run-1')).toBeUndefined();
});
});
});

View File

@ -54,6 +54,7 @@ import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors';
import type { CredentialsService } from '@/credentials/credentials.service';
import type { Push } from '@/push';
import type { Publisher } from '@/scaling/pubsub/publisher.service';
import type { AiPreferenceService } from '@/services/ai-preference.service';
import type { ProjectService } from '@/services/project.service.ee';
import type { UrlService } from '@n8n/backend-services';
@ -146,6 +147,7 @@ describe('InstanceAiController', () => {
threadTabsService,
provenanceService,
threadSharing,
mock<AiPreferenceService>(),
);
const req = mock<AuthenticatedRequest>({ user: { id: USER_ID } });

View File

@ -7,6 +7,7 @@ import type {
InstanceAiThreadRunTarget,
} from '@n8n/api-types';
import { isRecord } from '@n8n/utils/is-record';
import { deepCopy } from 'n8n-workflow';
import type { PlannedBuildFollowUp } from './planned-task-action-runner';
import type { OrchestratorResumeReason } from './tracing/instance-ai-tracing.service';
@ -82,5 +83,21 @@ export function readResumedTurnOptions(value: unknown): AssistantTurnOptions {
}
export function toJsonObject(options: AssistantTurnOptions): Record<string, unknown> {
return JSON.parse(JSON.stringify(options)) as Record<string, unknown>;
// deepCopy keeps the input type and keeps keys whose values are undefined.
// The queue stores a plain record, and a missing value must not become a key.
const copied: unknown = deepCopy(options);
if (!isRecord(copied)) return {};
omitUndefined(copied);
return copied;
}
function omitUndefined(record: Record<string, unknown>): void {
for (const key of Object.keys(record)) {
const value = record[key];
if (value === undefined) {
delete record[key];
continue;
}
if (isRecord(value)) omitUndefined(value);
}
}

View File

@ -19,15 +19,22 @@ import {
InstanceAiEvalRestoreThreadRequest,
InstanceAiEvalSeedDataTableRowsRequest,
InstanceAiProvenanceListQuery,
InstanceAiPreferenceCardEditRequestDto,
InstanceAiPreferenceCardUndoRequestDto,
aiPreferenceScopeOf,
findSeedFolderIssues,
findUnbackedSeedWorkflowTools,
} from '@n8n/api-types';
import type {
InstanceAiAdminSettingsResponse,
InstanceAiEvalThreadMemoryResponse,
InstanceAiPreferenceCardEditResponse,
InstanceAiPreferenceCardEvent,
InstanceAiPreferenceCardUndoResponse,
InstanceAiProvenanceListResponse,
InstanceAiThreadTabsResponse,
InstanceAiWorkflowProvenanceResponse,
PreferenceCardPayload,
} from '@n8n/api-types';
import { ModuleRegistry } from '@n8n/backend-common';
import { GlobalConfig } from '@n8n/config';
@ -76,6 +83,7 @@ import { CredentialsService } from '@/credentials/credentials.service';
import { BadRequestError, ForbiddenError, NotFoundError } from '@n8n/errors';
import { Push } from '@/push';
import { Publisher } from '@/scaling/pubsub/publisher.service';
import { AiPreferenceService } from '@/services/ai-preference.service';
import { ProjectService } from '@/services/project.service.ee';
import { UrlService } from '@n8n/backend-services';
@ -113,6 +121,7 @@ export class InstanceAiController {
private readonly threadTabsService: InstanceAiThreadTabsService,
private readonly provenanceService: WorkflowProvenanceService,
private readonly threadSharing: ThreadSharingService,
private readonly aiPreferenceService: AiPreferenceService,
) {
this.gatewayApiKey = globalConfig.instanceAi.gatewayApiKey;
}
@ -144,6 +153,58 @@ export class InstanceAiController {
// The fold ignores preference-card facts when it anchors a turn, so a wrong
// pair can only mis-render that one card's state in the caller's own thread.
@Post('/threads/:threadId/preferences/:preferenceId/undo')
@GlobalScope('instanceAi:message')
async undoPreferenceCard(
req: AuthenticatedRequest,
_res: Response,
@Param('threadId') threadId: string,
@Param('preferenceId') preferenceId: string,
@Body body: InstanceAiPreferenceCardUndoRequestDto,
): Promise<InstanceAiPreferenceCardUndoResponse> {
this.requireInstanceAiEnabled();
await this.assertThreadAccess(req.user.id, threadId);
await this.aiPreferenceService.delete(req.user, preferenceId);
return {
ok: true,
event: this.preferenceCardEvent(req.user.id, body.runId, {
toolCallId: body.toolCallId,
preferenceId,
state: 'undone',
}),
};
}
@Post('/threads/:threadId/preferences/:preferenceId/edit')
@GlobalScope('instanceAi:message')
async editPreferenceCard(
req: AuthenticatedRequest,
_res: Response,
@Param('threadId') threadId: string,
@Param('preferenceId') preferenceId: string,
@Body body: InstanceAiPreferenceCardEditRequestDto,
): Promise<InstanceAiPreferenceCardEditResponse> {
this.requireInstanceAiEnabled();
await this.assertThreadAccess(req.user.id, threadId);
const preference = await this.aiPreferenceService.update(req.user, preferenceId, {
content: body.content,
scope: body.scope,
projectId: body.projectId,
userId: body.userId,
});
return {
preference,
event: this.preferenceCardEvent(req.user.id, body.runId, {
toolCallId: body.toolCallId,
preferenceId,
state: 'edited',
content: preference.content,
scope: aiPreferenceScopeOf(preference),
projectId: preference.projectId,
}),
};
}
// ── Credits ──────────────────────────────────────────────────────────────
@Get('/credits')
@ -1010,6 +1071,21 @@ export class InstanceAiController {
* Verify thread ownership. Throws ForbiddenError if another user owns it.
* @param allowNew When true, a non-existent thread is permitted (new conversation).
*/
private preferenceCardEvent(
userId: string,
runId: string,
payload: PreferenceCardPayload,
): InstanceAiPreferenceCardEvent {
return {
type: 'preference-card',
runId,
agentId: 'root',
userId,
ts: Date.now(),
payload,
};
}
private async assertThreadAccess(
userId: string,
threadId: string,

View File

@ -1,11 +1,7 @@
import type {
InstanceAiEnsureThreadResponse,
} from '@n8n/api-types';
import type { InstanceAiEnsureThreadResponse } from '@n8n/api-types';
import type { User } from '@n8n/db';
import { Service } from '@n8n/di';
import {
loadInstanceAiRuntimeSkillSource,
} from '@n8n/instance-ai';
import { loadInstanceAiRuntimeSkillSource } from '@n8n/instance-ai';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
import { UnexpectedError } from 'n8n-workflow';
import { z } from 'zod';
@ -120,5 +116,4 @@ export class InstanceAiOnboardingService {
});
return response;
}
}

View File

@ -84,8 +84,6 @@ export class InstanceAiTracingService {
private readonly eventReader: InstanceAiTracingEventReader;
constructor(options: InstanceAiTracingServiceOptions) {
this.logger = options.logger;
this.eventReader = options.eventReader;

View File

@ -8153,6 +8153,32 @@
"instanceAi.message.actions.showDebugInfo": "Show debug information",
"instanceAi.message.actions.hideDebugInfo": "Hide debug information",
"instanceAi.emptyState.title": "n8n Assistant",
"instanceAi.homeMode.label": "Home mode",
"instanceAi.homeMode.user": "User",
"instanceAi.homeMode.power": "Power",
"instanceAi.homeMode.userHint": "Ask for anything.",
"instanceAi.projectHome.title": "Project home",
"instanceAi.projectHome.lead": "Workflows, agents, and approvals in this project.",
"instanceAi.projectHome.buildWebsite": "Build a website",
"instanceAi.projectHome.mcp": "Every capability runs through n8n MCP. This project is the boundary.",
"instanceAi.projectHome.agents.title": "Agents",
"instanceAi.projectHome.agents.body": "n8n Assistant is ready. Software factory is idle.",
"instanceAi.projectHome.approvals.title": "Approvals",
"instanceAi.projectHome.approvals.body": "No approvals are waiting.",
"instanceAi.projectHome.people.title": "People",
"instanceAi.projectHome.people.body": "You are here. Teammates in this project can open the same work.",
"instanceAi.codeMode.title": "Code mode",
"instanceAi.codeMode.worktree": "site/draft",
"instanceAi.codeMode.draft": "Draft",
"instanceAi.codeMode.close": "Close code mode",
"instanceAi.codeMode.preview": "Preview",
"instanceAi.codeMode.code": "Code",
"instanceAi.codeMode.previewTitle": "Website draft preview",
"instanceAi.codeMode.keep": "Keep in chat",
"instanceAi.codeMode.promote": "Promote to workflow",
"instanceAi.codeMode.publish": "Publish",
"instanceAi.codeMode.promoteBody": "The site can become a workflow in this project, or it can stay in this chat.",
"instanceAi.codeMode.published": "Marked to publish. The page stays in this chat until a workflow build runs.",
"instanceAi.limitedMode.title": "n8n Assistant is in limited mode",
"instanceAi.limitedMode.description": "It cannot create or edit workflows because data sharing is turned off.",
"instanceAi.limitedMode.settingsLink": "Turn on data sharing in AI usage settings",

View File

@ -114,6 +114,7 @@ onBeforeUnmount(() => {
:download="item.href ? item.attachment.fileName : undefined"
:class="$style.fileChip"
:title="item.attachment.fileName"
data-test-id="chat-file"
>
<N8nIcon icon="paperclip" size="small" />
<span :class="$style.fileName">{{ item.attachment.fileName }}</span>

View File

@ -45,6 +45,15 @@ const props = defineProps<{
dismissedFixToolCallIds?: string[];
canIncreaseBudget?: boolean;
budgetIncreasePending?: boolean;
/**
* Marks user and assistant bubbles with the Instance AI test ids. Project
* agent chat leaves this off.
*/
tagInstanceMessages?: boolean;
}>();
defineSlots<{
'user-message-extra'?: (props: { message: ChatMessage }) => unknown;
}>();
const emit = defineEmits<{
@ -65,6 +74,15 @@ function onFixWithAssistant(group: DisplayGroup, failures: AgentFixWithAssistant
emit('sendToAssistant', { executionId, failures });
}
function instanceMessageTestId(role: 'user' | 'assistant'): string | undefined {
if (!props.tagInstanceMessages) return undefined;
return role === 'user' ? 'instance-ai-user-message' : 'instance-ai-assistant-message';
}
function isQuotaExhaustedMessage(message: ChatMessage | undefined): boolean {
return message?.errorCode === 'quota_exhausted';
}
function onInteractiveSubmit(payload: InteractivePayload, resumeData: unknown) {
// Cards without a runId are disabled at the card level (see InteractiveCard).
// This guard is a defensive belt-and-braces for the type narrowing.
@ -466,7 +484,11 @@ watch(
<div v-if="group.kind === 'backgroundJobSignal'" :class="[$style.message, $style.assistant]">
<AgentChatBackgroundJobSignal :class="$style.content" :signal="group.signal" />
</div>
<div v-else-if="group.kind === 'toolRun'" :class="[$style.message, $style.assistant]">
<div
v-else-if="group.kind === 'toolRun'"
:class="[$style.message, $style.assistant]"
:data-test-id="instanceMessageTestId('assistant')"
>
<div :class="$style.content">
<AgentChatToolSteps
v-if="group.toolCalls.length"
@ -500,7 +522,16 @@ watch(
/>
</div>
<div
v-if="group.finalMessage?.content"
v-if="isQuotaExhaustedMessage(group.finalMessage)"
data-test-id="instance-ai-out-of-credits"
>
<N8nText tag="p">{{ i18n.baseText('instanceAi.error.outOfCredits.title') }}</N8nText>
<N8nButton size="small" data-test-id="instance-ai-out-of-credits-upgrade">
{{ i18n.baseText('instanceAi.error.outOfCredits.upgrade') }}
</N8nButton>
</div>
<div
v-else-if="group.finalMessage?.content"
:class="[
$style.chatMessage,
{ [$style.chatMessageError]: group.finalMessage.status === 'error' },
@ -565,6 +596,7 @@ watch(
<div
v-else
:class="[$style.message, group.message.role === 'user' ? $style.user : $style.assistant]"
:data-test-id="instanceMessageTestId(group.message.role)"
>
<div :class="$style.content">
<AgentChatToolSteps
@ -612,6 +644,20 @@ watch(
>
{{ group.message.content }}
</div>
<slot
v-if="group.message.role === 'user'"
name="user-message-extra"
:message="group.message"
/>
<div
v-if="isQuotaExhaustedMessage(group.message)"
data-test-id="instance-ai-out-of-credits"
>
<N8nText tag="p">{{ i18n.baseText('instanceAi.error.outOfCredits.title') }}</N8nText>
<N8nButton size="small" data-test-id="instance-ai-out-of-credits-upgrade">
{{ i18n.baseText('instanceAi.error.outOfCredits.upgrade') }}
</N8nButton>
</div>
<template v-else>
<template v-for="item in getMessageRenderItems(group.message)" :key="item.key">
<div

View File

@ -41,7 +41,7 @@ import ChatInputBase from '@/features/ai/shared/components/ChatInputBase.vue';
import AttachmentPreview from '@/features/ai/instanceAi/components/AttachmentPreview.vue';
import { useAgentChatStream } from '../composables/useAgentChatStream';
import { useComposerFocus } from '../composables/useComposerFocus';
import type { InteractivePayload } from '@/features/ai/shared/agentsChat/types';
import type { ChatMessage, InteractivePayload } from '@/features/ai/shared/agentsChat/types';
import {
findTailOpenInteractive,
getMessageInteractives,
@ -117,6 +117,8 @@ const props = withDefaults(
* from its own `footer-start` menu turns it off and calls `openFilePicker`.
*/
showAttachButton?: boolean;
/** Marks transcript bubbles with the Instance AI test ids. */
tagInstanceMessages?: boolean;
}>(),
{
visible: true,
@ -135,6 +137,7 @@ const props = withDefaults(
attachmentAccept: undefined,
placeholder: undefined,
showAttachButton: true,
tagInstanceMessages: false,
},
);
@ -167,6 +170,10 @@ defineSlots<{
'composer-attachments'?: () => unknown;
/** Replaces the composer, for example with a notice for a reader who cannot send. */
composer?: () => unknown;
/** Controls at the end of the composer row, such as the mention picker. */
'right-actions'?: () => unknown;
/** Chips rendered inside a user bubble, such as mentioned workflows. */
'user-message-extra'?: (props: { message: ChatMessage }) => unknown;
}>();
const locale = useI18n();
@ -1040,6 +1047,7 @@ defineExpose({
setDraft,
clearBudgetStops,
openFilePicker: () => chatInput.value?.openFilePicker(),
getInputElement: () => chatInput.value?.getInputElement(),
// Read-only views of the chat state, for hosts that derive side panels from it.
messages: computed(() => messages.value),
isStreaming,
@ -1133,10 +1141,15 @@ onBeforeUnmount(() => {
:dismissed-fix-tool-call-ids="dismissedFixToolCallIds"
:can-increase-budget="canIncreaseBudget"
:budget-increase-pending="budgetIncreasePending"
:tag-instance-messages="tagInstanceMessages"
@resume="resume"
@send-to-assistant="emit('send-to-assistant', $event)"
@increase-budget="onIncreaseBudget"
/>
>
<template #user-message-extra="slotProps">
<slot name="user-message-extra" :message="slotProps.message" />
</template>
</AgentChatMessageList>
<slot name="inline-offers" />
@ -1512,6 +1525,9 @@ onBeforeUnmount(() => {
<template #footer-start>
<slot name="footer-start" />
</template>
<template v-if="$slots['right-actions']" #right-actions>
<slot name="right-actions" />
</template>
</ChatInputBase>
</div>
</aside>

View File

@ -1252,7 +1252,20 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
markInFlightStateFailed(session);
if (event.errorCode === 'agent_misconfigured') {
fatalError.value = { message: event.message, missing: event.missing ?? [] };
} else if (session.userMessage && !session.executionId) {
} else if (event.errorCode === 'quota_exhausted' || session.executionId) {
// A quota stop must land in the transcript. A send that never
// started an execution stays a toast, except for that stop.
messages.value.push(
reactive<ChatMessage>({
id: crypto.randomUUID(),
role: 'assistant',
content: event.message,
toolCalls: [],
status: CHAT_MESSAGE_STATUS.ERROR,
...(event.errorCode ? { errorCode: event.errorCode } : {}),
}),
);
} else if (session.userMessage) {
showSendError(event.message, session);
} else {
messages.value.push(
@ -1262,6 +1275,7 @@ export function useAgentChatStream(params: UseAgentChatStreamParams) {
content: event.message,
toolCalls: [],
status: CHAT_MESSAGE_STATUS.ERROR,
...(event.errorCode ? { errorCode: event.errorCode } : {}),
}),
);
}

View File

@ -10,7 +10,7 @@ import type {
InstanceAiThreadSource,
} from '@n8n/api-types';
import { useI18n, type BaseTextKey } from '@n8n/i18n';
import { useChatInputAutoFocus } from '@n8n/design-system';
import { N8nSegmentControl, N8nText, useChatInputAutoFocus } from '@n8n/design-system';
import { useToast } from '@n8n/composables/useToast';
import { useTelemetry } from '@n8n/composables/useTelemetry';
import { TELEMETRY_EVENT } from '@n8n/telemetry';
@ -39,6 +39,9 @@ import {
stashPendingFirstMessage,
stashPendingFirstMessageFiles,
} from './composables/useInstanceAiHandoff';
import { isWebsiteBuildRequest, writeCodeMode } from './codeMode/codeMode';
import { readHomeMode, writeHomeMode, type AssistantHomeMode } from './homeMode/homeMode';
import InstanceAiProjectHome from './components/InstanceAiProjectHome.vue';
import { fileAttachmentToFile } from './utils/fileAttachments';
import { useCreditWarningBanner } from './composables/useCreditWarningBanner';
import {
@ -563,6 +566,7 @@ async function handleSubmit(
const projectId = selectedProject.value;
const threadId = uuidv4();
if (isWebsiteBuildRequest(message)) writeCodeMode(threadId);
isStartingThread.value = true;
// Persist the thread on the BE first. Otherwise we'd navigate to
@ -624,11 +628,37 @@ function handleShelfSuggestionInsert(payload: ShelfSuggestionPayload) {
splitPreviewPromptKey.value = null;
void chatInputRef.value?.insertSuggestion(payload);
}
function askForWebsite() {
chatInputRef.value?.setText('Make me a website');
void nextTick(() => chatInputRef.value?.focus());
}
const homeMode = ref<AssistantHomeMode>(readHomeMode());
const showHomeMode = computed(() => !showProactiveStarter.value && !isSplitVariantEnabled.value);
const homeModeOptions = computed(() => [
{ label: i18n.baseText('instanceAi.homeMode.user'), value: 'user' as const },
{ label: i18n.baseText('instanceAi.homeMode.power'), value: 'power' as const },
]);
watch(homeMode, (mode) => {
writeHomeMode(mode);
});
</script>
<template>
<div :class="$style.chatArea">
<InstanceAiViewHeader v-if="!isSplitLayoutActive" />
<InstanceAiViewHeader v-if="!isSplitLayoutActive">
<template v-if="showHomeMode" #actions>
<N8nSegmentControl
v-model="homeMode"
size="small"
:options="homeModeOptions"
:aria-label="i18n.baseText('instanceAi.homeMode.label')"
data-test-id="instance-ai-home-mode"
/>
</template>
</InstanceAiViewHeader>
<div :class="$style.contentArea">
<div v-if="showProactiveStarter" :class="$style.proactiveLayout">
@ -713,8 +743,23 @@ function handleShelfSuggestionInsert(payload: ShelfSuggestionPayload) {
</div>
</template>
</InstanceAiSplitEmptyState>
<div v-else ref="emptyLayout" :class="$style.emptyLayout">
<InstanceAiEmptyState :title-key="emptyStateTitleKey" :show-title-icon="true" />
<div
v-else
ref="emptyLayout"
:class="[$style.emptyLayout, homeMode === 'power' && $style.emptyLayoutPower]"
>
<div :class="[$style.homeStack, homeMode === 'power' && $style.homeStackPower]">
<InstanceAiEmptyState :title-key="emptyStateTitleKey" :show-title-icon="true" />
<N8nText
v-if="homeMode === 'user'"
size="small"
color="text-light"
:class="$style.userHint"
>
{{ i18n.baseText('instanceAi.homeMode.userHint') }}
</N8nText>
<InstanceAiProjectHome v-else @build-website="askForWebsite" />
</div>
<div ref="centeredInput" :class="$style.centeredInput">
<InstanceAiFreeNudge
:eligible="
@ -818,6 +863,28 @@ function handleShelfSuggestionInsert(payload: ShelfSuggestionPayload) {
overflow-x: hidden;
}
.emptyLayoutPower {
padding-top: var(--spacing--lg);
}
.homeStack {
width: 100%;
/* Matches the composer width. No spacing token is 680px. */
max-width: 680px;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--spacing--sm);
}
.homeStackPower {
align-items: stretch;
}
.userHint {
text-align: center;
}
.centeredInput {
width: 100%;
max-width: 680px;

View File

@ -52,6 +52,8 @@ import { buildFixWithAiPrompt } from './fixWithAi';
import { isAgentWorthTesting, testAgentOfferKey } from './testAgentOffer';
import InstanceAiDataTablePreview from './components/InstanceAiDataTablePreview.vue';
import InstanceAiAgentPreview from './components/InstanceAiAgentPreview.vue';
import InstanceAiCodeModePanel from './components/InstanceAiCodeModePanel.vue';
import { isWebsiteBuildRequest, readCodeMode, writeCodeMode } from './codeMode/codeMode';
import { TabsRoot } from 'reka-ui';
import { useAgentEvalsFlag } from '@/features/ai/evaluation.ee/composables/useAgentEvalsFlag';
import { useAgentCapabilitySummary } from '@/features/agents/composables/useAgentCapabilitySummary';
@ -725,6 +727,35 @@ async function persistTestAgentOfferDismissal(agentId: string) {
function handleNewThreadClick() {
void router.push({ name: INSTANCE_AI_VIEW });
}
const codeModeDismissed = ref(false);
const codeModeRequested = ref(readCodeMode(props.threadId));
const websiteRequestCount = computed(
() =>
thread.messages.filter(
(message) => message.role === 'user' && isWebsiteBuildRequest(message.content),
).length,
);
const seenWebsiteRequests = ref(websiteRequestCount.value);
watch(websiteRequestCount, (count) => {
if (count <= seenWebsiteRequests.value) return;
seenWebsiteRequests.value = count;
writeCodeMode(props.threadId);
codeModeRequested.value = true;
codeModeDismissed.value = false;
});
const showCodeMode = computed(
() =>
(codeModeRequested.value || websiteRequestCount.value > 0) &&
!codeModeDismissed.value &&
!preview.isPreviewVisible.value,
);
function closeCodeMode() {
codeModeDismissed.value = true;
}
</script>
<template>
@ -982,6 +1013,7 @@ function handleNewThreadClick() {
</N8nResizeWrapper>
</div>
</Transition>
<InstanceAiCodeModePanel v-if="showCodeMode" :class="$style.codeMode" @close="closeCodeMode" />
<!-- Experiment cleanup: remove with openWorkflowInAssistant. -->
<OpenWorkflowInAssistantNotification :thread-id="threadId" />
</div>
@ -1032,6 +1064,14 @@ function handleNewThreadClick() {
border-left: var(--border);
}
.codeMode {
/* Panel width. No spacing token matches 22rem, 28rem, or 40rem. */
flex: 1 1 28rem;
min-width: 22rem;
max-width: 40rem;
min-height: 0;
}
.canvasAreaExpanded {
position: absolute;
inset: 0;

View File

@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import userEvent from '@testing-library/user-event';
import { createComponentRenderer } from '@/__tests__/render';
import InstanceAiCodeModePanel from '../components/InstanceAiCodeModePanel.vue';
import InstanceAiProjectHome from '../components/InstanceAiProjectHome.vue';
const renderPanel = createComponentRenderer(InstanceAiCodeModePanel);
const renderHome = createComponentRenderer(InstanceAiProjectHome);
describe('InstanceAiCodeModePanel', () => {
it('opens on the website preview', () => {
const { getByTestId } = renderPanel();
expect(getByTestId('instance-ai-code-mode')).toHaveTextContent('Code mode');
expect(getByTestId('instance-ai-code-mode')).toHaveTextContent('site/draft');
expect(getByTestId('instance-ai-code-mode-frame')).toBeTruthy();
});
it('shows the starter page source', async () => {
const user = userEvent.setup();
const { getByTestId, queryByTestId } = renderPanel();
await user.click(getByTestId('instance-ai-code-mode-code'));
expect(queryByTestId('instance-ai-code-mode-frame')).toBeNull();
expect(getByTestId('instance-ai-code-mode-source')).toHaveTextContent('Your website');
expect(getByTestId('instance-ai-code-mode-source')).toHaveTextContent('<!DOCTYPE html>');
});
it('asks before marking the draft to publish', async () => {
const user = userEvent.setup();
const { getByTestId, queryByText } = renderPanel();
await user.click(getByTestId('instance-ai-code-mode-promote'));
expect(getByTestId('instance-ai-code-mode')).toHaveTextContent(
'The site can become a workflow in this project, or it can stay in this chat.',
);
await user.click(getByTestId('instance-ai-code-mode-publish'));
expect(getByTestId('instance-ai-code-mode')).toHaveTextContent(
'Marked to publish. The page stays in this chat until a workflow build runs.',
);
expect(queryByText('Promote to workflow')).toBeNull();
});
it('closes from the header', async () => {
const user = userEvent.setup();
const { emitted, getByTestId } = renderPanel();
await user.click(getByTestId('instance-ai-code-mode-close'));
expect(emitted().close).toEqual([[]]);
});
});
describe('InstanceAiProjectHome', () => {
it('shows the project inventory and the website action', async () => {
const user = userEvent.setup();
const { emitted, getByTestId } = renderHome();
expect(getByTestId('instance-ai-project-home')).toHaveTextContent('Project home');
expect(getByTestId('instance-ai-project-home')).toHaveTextContent(
'Workflows, agents, and approvals in this project.',
);
expect(getByTestId('instance-ai-project-home')).toHaveTextContent('Agents');
expect(getByTestId('instance-ai-project-home')).toHaveTextContent('Approvals');
expect(getByTestId('instance-ai-project-home')).toHaveTextContent('People');
await user.click(getByTestId('instance-ai-build-website'));
expect(emitted()['build-website']).toEqual([[]]);
});
});

View File

@ -900,6 +900,20 @@ describe('InstanceAiEmptyView', () => {
expect(queryByTestId('instance-ai-split-empty-state')).not.toBeInTheDocument();
});
it('keeps the home in user mode until power mode is selected', async () => {
const { getByRole, getByText, queryByTestId } = renderView();
expect(getByRole('radio', { name: 'User' })).toBeChecked();
expect(getByText('Ask for anything.')).toBeVisible();
expect(queryByTestId('instance-ai-project-home')).not.toBeInTheDocument();
await fireEvent.click(getByRole('radio', { name: 'Power' }));
expect(getByRole('radio', { name: 'Power' })).toBeChecked();
expect(queryByTestId('instance-ai-project-home')).toBeInTheDocument();
expect(getByText('Workflows, agents, and approvals in this project.')).toBeVisible();
});
it('keeps the free nudge ineligible until credits are loaded', async () => {
store.creditsQuota = undefined;

View File

@ -4,8 +4,6 @@ import { createComponentRenderer, type RenderOptions } from '@/__tests__/render'
import { provideThread, useInstanceAiStore, type ThreadRuntime } from '../instanceAi.store';
import type { FrontendModuleSettings, InstanceAiMessage } from '@n8n/api-types';
type RendererOptions = { merge?: boolean };
/** A bare, all-mocked `ThreadRuntime` for components that only need the shape, not real behaviour. */

View File

@ -314,7 +314,9 @@ export function formatApprovalTitle(params: {
if (!toolName) return undefined;
const i18n = useI18n();
const action = typeof args?.action === 'string' ? args.action : undefined;
const keyBase = action ? `instanceAi.tools.${toolName}.${action}` : `instanceAi.tools.${toolName}`;
const keyBase = action
? `instanceAi.tools.${toolName}.${action}`
: `instanceAi.tools.${toolName}`;
if (resourceName) {
const namedKey = approvalTitleKeys.get(`${keyBase}.imperativeWithResource`);
if (namedKey) {

View File

@ -0,0 +1,15 @@
import { isWebsiteBuildRequest } from './codeMode';
describe('isWebsiteBuildRequest', () => {
it('matches a request to make a website', () => {
expect(isWebsiteBuildRequest('Make me a website')).toBe(true);
expect(isWebsiteBuildRequest('Build a landing page for the launch')).toBe(true);
expect(isWebsiteBuildRequest('Can you design a site for Acme?')).toBe(true);
expect(isWebsiteBuildRequest('Show code mode')).toBe(true);
});
it('leaves ordinary chat alone', () => {
expect(isWebsiteBuildRequest('Say hello back to me')).toBe(false);
expect(isWebsiteBuildRequest('Build a workflow that posts to Slack')).toBe(false);
});
});

View File

@ -0,0 +1,83 @@
const WEBSITE_BUILD_REQUEST =
/\b(website|web\s*site|landing page|web\s?app|web\s?page|webpage|home\s?page|code mode)\b/i;
const BUILD_A_SITE_REQUEST = /\b(make|build|create|design)\b[\s\S]{0,40}\bsite\b/i;
/** A chat request that should open code mode. */
export function isWebsiteBuildRequest(text: string): boolean {
return WEBSITE_BUILD_REQUEST.test(text) || BUILD_A_SITE_REQUEST.test(text);
}
export function codeModeStorageKey(threadId: string): string {
return `n8n-instance-ai-code-mode:${threadId}`;
}
export function writeCodeMode(threadId: string): void {
sessionStorage.setItem(codeModeStorageKey(threadId), '1');
}
export function readCodeMode(threadId: string): boolean {
return sessionStorage.getItem(codeModeStorageKey(threadId)) === '1';
}
export const CODE_MODE_HTML = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Site draft</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<main>
<p class="eyebrow">Draft</p>
<h1>Your website</h1>
<p>This page is open in code mode. Ask the assistant to change the headline, the sections, or the layout.</p>
<a href="#start">Start here</a>
</main>
</body>
</html>
`;
export const CODE_MODE_CSS = `body {
margin: 0;
font-family: Georgia, serif;
background: #f6f3ee;
color: #1c1917;
}
main {
max-width: 36rem;
margin: 0 auto;
padding: 4rem 1.5rem;
}
.eyebrow {
letter-spacing: 0.08em;
text-transform: uppercase;
font-size: 0.75rem;
}
h1 {
font-size: 2.5rem;
line-height: 1.1;
margin: 0.5rem 0 1rem;
}
a {
color: #9a3412;
}
`;
export const CODE_MODE_PREVIEW_DOCUMENT = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Site draft</title>
<style>${CODE_MODE_CSS}</style>
</head>
<body>
<main>
<p class="eyebrow">Draft</p>
<h1>Your website</h1>
<p>This page is open in code mode. Ask the assistant to change the headline, the sections, or the layout.</p>
<a href="#start">Start here</a>
</main>
</body>
</html>
`;

View File

@ -90,7 +90,10 @@ async function confirm(decision: InstanceGatewayResourceDecision) {
// hostname or a URL, so it goes through the egress policy like any other
// free-form value.
telemetry.track('User finished providing input', redactTelemetryProperties(eventProps));
await transport.confirm(props.requestId, { kind: 'resourceDecision', resourceDecision: decision });
await transport.confirm(props.requestId, {
kind: 'resourceDecision',
resourceDecision: decision,
});
}
</script>

View File

@ -10,7 +10,16 @@
* context (time zone, push ref, open tabs, hand-off context and resource
* references) as `hostContext`.
*/
import { computed, nextTick, onBeforeUnmount, onMounted, ref, useTemplateRef, watch } from 'vue';
import {
computed,
inject,
nextTick,
onBeforeUnmount,
onMounted,
ref,
useTemplateRef,
watch,
} from 'vue';
import type {
InstanceAiAgentAttachment,
InstanceAiAttachment,
@ -31,7 +40,18 @@ import AgentChatPanel from '@/features/agents/components/AgentChatPanel.vue';
import { useAgentExecutionUpdates } from '@/features/agents/composables/useAgentExecutionUpdates';
import { ASSISTANT_CONFIRMATION_TOOL_NAME } from '@/features/ai/shared/agentsChat/assistantConfirmation';
import { findTailOpenInteractive } from '@/features/ai/shared/agentsChat/messageMappers';
import type { InteractivePayload } from '@/features/ai/shared/agentsChat/types';
import type { ChatMessage, InteractivePayload } from '@/features/ai/shared/agentsChat/types';
import AssistantAtMentionPicker from '@/features/ai/assistant-at-mentions/AssistantAtMentionPicker.vue';
import { useAssistantAtMentions } from '@/features/ai/assistant-at-mentions/composables/useAssistantAtMentions';
import { useAssistantMentionAttachments } from '@/features/ai/assistant-at-mentions/composables/useAssistantMentionAttachments';
import { useAssistantMentionAvailability } from '@/features/ai/assistant-at-mentions/composables/useAssistantMentionAvailability';
import { useIsAssistantAtMentionsEnabled } from '@/features/ai/assistant-at-mentions/composables/useIsAssistantAtMentionsEnabled';
import type {
AssistantMentionArtifactReference,
AssistantMentionSelection,
WorkflowArtifactReference,
} from '@/features/ai/assistant-at-mentions/assistantAtMentions.types';
import { buildMentionKey } from '@/features/ai/assistant-at-mentions/utils/buildMentionItems';
// Experiment cleanup: remove with openWorkflowInAssistant.
import { useOpenWorkflowInAssistantStore } from '@/experiments/openWorkflowInAssistant/stores/openWorkflowInAssistant.store';
import { useInstanceAiStore, useThread } from '../instanceAi.store';
@ -143,8 +163,60 @@ function composerResumeData(payload: InteractivePayload, text: string): unknown
const chatMessages = computed(() => chatPanel.value?.messages ?? []);
const isChatStreaming = computed(() => chatPanel.value?.isStreaming ?? false);
const isChatLoadingHistory = computed(() => chatPanel.value?.isLoadingHistory ?? false);
interface SentResourceEntry {
content: string;
attachments: InstanceAiResourceAttachment[];
}
function sentResourceStorageKey(): string {
return `n8n-instance-ai-message-resources:${thread.id}`;
}
function readSentResources(): SentResourceEntry[] {
try {
const raw = sessionStorage.getItem(sentResourceStorageKey());
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as SentResourceEntry[]) : [];
} catch {
return [];
}
}
/** Mention chips ride the user bubble. Session storage keeps them across a reload. */
const sentResourceLog = ref<SentResourceEntry[]>(readSentResources());
function rememberSentResources(content: string, attachments: InstanceAiResourceAttachment[]): void {
if (attachments.length === 0) return;
sentResourceLog.value = [...sentResourceLog.value, { content, attachments }];
sessionStorage.setItem(sentResourceStorageKey(), JSON.stringify(sentResourceLog.value));
}
const resourcesByMessageId = computed(() => {
const pending = [...sentResourceLog.value];
const byId = new Map<string, InstanceAiResourceAttachment[]>();
for (const message of chatMessages.value) {
if (message.role !== 'user') continue;
const index = pending.findIndex((entry) => entry.content === message.content.trim());
if (index < 0) continue;
const [entry] = pending.splice(index, 1);
if (!entry) continue;
byId.set(message.id, entry.attachments);
}
return byId;
});
function resourcesForChatMessage(message: ChatMessage): InstanceAiResourceAttachment[] {
return resourcesByMessageId.value.get(message.id) ?? [];
}
const mirroredMessages = computed(() =>
agentsChatToThreadMessages(chatMessages.value, isChatStreaming.value),
agentsChatToThreadMessages(chatMessages.value, isChatStreaming.value).map((message) => {
if (message.role !== 'user') return message;
const attachments = resourcesByMessageId.value.get(message.id);
return attachments?.length ? { ...message, attachments } : message;
}),
);
const isAwaitingInput = computed(() => findTailOpenInteractive(chatMessages.value) !== undefined);
@ -246,6 +318,111 @@ const pendingAgentAttachment = ref<InstanceAiAgentAttachment | null>(
);
/** Resource references staged in the composer (canvas node selections). */
const composerResources = ref<InstanceAiResourceAttachment[]>([]);
const mentionsFlag = useIsAssistantAtMentionsEnabled();
const draftText = ref('');
const mentionFiles = ref<File[]>([]);
const openWorkflowPreview = inject<((workflowId: string) => boolean) | undefined>(
'openWorkflowPreview',
undefined,
);
const shouldShowMentions = computed(
() => mentionsFlag.value && Boolean(projectId.value) && !isTeammate.value,
);
const mentionArtifacts = computed((): WorkflowArtifactReference[] =>
[...thread.producedArtifacts.values()]
.filter((entry) => entry.type === 'workflow')
.map((entry) => ({
id: entry.id,
name: entry.name,
origin: thread.producedArtifactOrigins.get(entry.id),
})),
);
const mentionAvailability = useAssistantMentionAvailability({
enabled: shouldShowMentions,
projectId: () => projectId.value,
artifacts: () => mentionArtifacts.value,
});
const canUseMentions = computed(
() => shouldShowMentions.value && mentionAvailability.isAvailable.value,
);
const mentions = useAssistantAtMentions({
text: draftText,
enabled: canUseMentions,
getInputElement: () => chatPanel.value?.getInputElement() ?? undefined,
});
const mentionMenuOpen = mentions.menuOpen;
const mentionQuery = mentions.query;
const excludedMentionContext = computed(() => {
const keys = new Set<string>();
const workflowIds = new Set<string>();
for (const attachment of composerResources.value) {
if (attachment.type === 'workflow') {
keys.add(buildMentionKey('workflow', attachment.id, attachment.id));
workflowIds.add(attachment.id);
continue;
}
if (attachment.type !== 'nodes') continue;
for (const set of attachment.sets) {
if (set.canvasGroupId) {
keys.add(buildMentionKey('group', attachment.workflowId, set.canvasGroupId));
continue;
}
for (const node of set.nodes) {
keys.add(buildMentionKey('node', attachment.workflowId, node.id));
}
}
}
return { keys: [...keys], workflowIds: [...workflowIds] };
});
const mentionAttachments = useAssistantMentionAttachments({
files: mentionFiles,
resources: composerResources,
projectId: () => projectId.value,
reservedAttachmentCount: () => 0,
onReferenceAdded: (reference: AssistantMentionArtifactReference) => {
thread.upsertTransientWorkflowReference({
referenceId: reference.referenceId,
workflowId: reference.workflowId,
workflowName: reference.workflowName,
...(projectId.value ? { projectId: projectId.value } : {}),
});
void nextTick(() => {
openWorkflowPreview?.(reference.workflowId);
});
},
onReferenceRemoved: (referenceId) => {
thread.removeTransientWorkflowReference(referenceId);
},
onCleared: () => mentions.close(false, 'unavailable'),
});
// Availability can resolve after the first `@`. Re-read the draft so the menu still opens.
watch(canUseMentions, (enabled, wasEnabled) => {
if (enabled && !wasEnabled) void mentions.handleTextChange(draftText.value);
});
function onDraftUpdate(value: string): void {
if (!shouldShowMentions.value) return;
void mentions.handleTextChange(value);
}
async function handleMentionSelection(selection: AssistantMentionSelection): Promise<void> {
const result = mentionAttachments.select(selection);
if (result.status === 'limit') {
toast.showError(
new Error(i18n.baseText('instanceAi.mentions.attachmentLimitMessage')),
i18n.baseText('instanceAi.mentions.attachmentLimitTitle'),
);
return;
}
if (result.truncated) {
toast.showError(
new Error(i18n.baseText('instanceAi.nodeContext.truncated.message')),
i18n.baseText('instanceAi.nodeContext.truncated.title'),
);
}
await mentions.replaceActiveRange(selection.item.label);
}
/** Text n8n wrote into the composer, to tell a pre-fill from a typed message. */
const activePrefill = ref<InstanceAiPrefillPayload | null>(null);
/** A programmatic send: its context replaces the composer state for one message. */
@ -436,6 +613,14 @@ function readHostAttachments(hostContext: Record<string, unknown>): InstanceAiAt
return Array.isArray(attachments) ? (attachments as InstanceAiAttachment[]) : [];
}
function resourceAttachmentsOf(
attachments: InstanceAiAttachment[],
): InstanceAiResourceAttachment[] {
return attachments.filter(
(attachment): attachment is InstanceAiResourceAttachment => attachment.type !== 'file',
);
}
/** A message the user typed was accepted: settle the hand-off state it carried. */
function onMessageAccepted(payload: {
text: string;
@ -446,7 +631,11 @@ function onMessageAccepted(payload: {
messageRunTarget.accepted(hostContext);
const attachments = readHostAttachments(hostContext);
thread.recordSentAttachments(attachments);
if (programmaticHostContexts.has(hostContext)) return;
// The empty view sends the opener. Its chips are on that payload, not in this composer.
if (programmaticHostContexts.has(hostContext)) {
rememberSentResources(payload.text.trim(), resourceAttachmentsOf(attachments));
return;
}
const prefill = activePrefill.value;
const authorship: InstanceAiMessageAuthorship = prefill
@ -463,6 +652,7 @@ function onMessageAccepted(payload: {
attachmentCount: attachments.length + payload.files.length,
});
rememberSentResources(payload.text.trim(), [...composerResources.value]);
const nodeCount = countAttachedNodes(attachments);
if (nodeCount > 0) {
telemetry.track(TELEMETRY_EVENT.INSTANCE_AI.USER_SENT_CHAT_MESSAGE_WITH_NODES, {
@ -616,7 +806,13 @@ onBeforeUnmount(() => {
</script>
<template>
<div :class="$style.conversation" data-test-id="instance-ai-agents-conversation">
<div
:class="$style.conversation"
data-test-id="instance-ai-agents-conversation"
@pointerdown.capture="shouldShowMentions && mentions.saveSelection()"
@click.capture="shouldShowMentions && mentions.handleCaretMove()"
@keyup.capture="shouldShowMentions && mentions.handleCaretMove()"
>
<AgentChatPanel
v-if="projectId"
ref="chatPanel"
@ -632,7 +828,10 @@ onBeforeUnmount(() => {
attachment-accept=""
:show-attach-button="false"
:placeholder="composerPlaceholder"
:input-draft="shouldShowMentions ? draftText : undefined"
tag-instance-messages
mode="inline"
@update:input-draft="onDraftUpdate"
@message-accepted="onMessageAccepted"
@resume-failed="sharing.onResumeFailed"
>
@ -664,6 +863,27 @@ onBeforeUnmount(() => {
<template #footer-start>
<InstanceAiInputMenu :thread-id="thread.id" @attach-files="chatPanel?.openFilePicker()" />
</template>
<template v-if="shouldShowMentions" #right-actions>
<AssistantAtMentionPicker
v-model="mentionMenuOpen"
:query="mentionQuery"
:project-id="projectId"
:artifacts="mentionArtifacts"
:excluded-keys="excludedMentionContext.keys"
:excluded-workflow-ids="excludedMentionContext.workflowIds"
:input-element="chatPanel?.getInputElement() ?? null"
:disabled="!canUseMentions"
@update:model-value="mentions.handleMenuOpenChange"
@select="handleMentionSelection"
/>
</template>
<template #user-message-extra="{ message }">
<AttachmentPreview
v-for="(attachment, index) in resourcesForChatMessage(message)"
:key="`sent-${message.id}-${index}`"
:attachment="attachment"
/>
</template>
<template v-if="composerContextChip || composerResources.length" #composer-attachments>
<InstanceAiResourceChip
v-if="composerContextChip"

View File

@ -0,0 +1,272 @@
<script setup lang="ts">
import { computed, ref } from 'vue';
import { N8nButton, N8nIconButton, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
import { CODE_MODE_CSS, CODE_MODE_HTML, CODE_MODE_PREVIEW_DOCUMENT } from '../codeMode/codeMode';
const emit = defineEmits<{ close: [] }>();
const i18n = useI18n();
const view = ref<'preview' | 'code'>('preview');
const activeFile = ref<'index.html' | 'styles.css'>('index.html');
const promoteOpen = ref(false);
const published = ref(false);
const fileSource = computed(() =>
activeFile.value === 'index.html' ? CODE_MODE_HTML : CODE_MODE_CSS,
);
const files = [
{ id: 'index.html' as const, label: 'index.html' },
{ id: 'styles.css' as const, label: 'styles.css' },
];
function showFile(file: 'index.html' | 'styles.css') {
activeFile.value = file;
view.value = 'code';
}
function keepInChat() {
promoteOpen.value = false;
published.value = false;
}
function publishDraft() {
published.value = true;
promoteOpen.value = false;
}
</script>
<template>
<aside :class="$style.panel" data-test-id="instance-ai-code-mode">
<header :class="$style.header">
<div :class="$style.titleRow">
<N8nText tag="h2" size="medium" bold>{{
i18n.baseText('instanceAi.codeMode.title')
}}</N8nText>
<span :class="$style.chip">{{ i18n.baseText('instanceAi.codeMode.worktree') }}</span>
<span :class="$style.chip">{{ i18n.baseText('instanceAi.codeMode.draft') }}</span>
</div>
<N8nIconButton
icon="x"
variant="ghost"
size="small"
:aria-label="i18n.baseText('instanceAi.codeMode.close')"
data-test-id="instance-ai-code-mode-close"
@click="emit('close')"
/>
</header>
<div :class="$style.toolbar" role="tablist">
<button
type="button"
role="tab"
:aria-selected="view === 'preview'"
:class="[$style.tab, view === 'preview' && $style.tabActive]"
data-test-id="instance-ai-code-mode-preview"
@click="view = 'preview'"
>
{{ i18n.baseText('instanceAi.codeMode.preview') }}
</button>
<button
type="button"
role="tab"
:aria-selected="view === 'code'"
:class="[$style.tab, view === 'code' && $style.tabActive]"
data-test-id="instance-ai-code-mode-code"
@click="view = 'code'"
>
{{ i18n.baseText('instanceAi.codeMode.code') }}
</button>
</div>
<div :class="$style.body">
<iframe
v-if="view === 'preview'"
:class="$style.preview"
sandbox=""
:title="i18n.baseText('instanceAi.codeMode.previewTitle')"
:srcdoc="CODE_MODE_PREVIEW_DOCUMENT"
data-test-id="instance-ai-code-mode-frame"
/>
<div v-else :class="$style.codeLayout">
<div :class="$style.files" role="list">
<button
v-for="file in files"
:key="file.id"
type="button"
role="listitem"
:class="[$style.file, activeFile === file.id && $style.fileActive]"
@click="showFile(file.id)"
>
{{ file.label }}
</button>
</div>
<pre :class="$style.source" data-test-id="instance-ai-code-mode-source">{{
fileSource
}}</pre>
</div>
</div>
<footer :class="$style.footer">
<N8nText v-if="published" size="small">{{
i18n.baseText('instanceAi.codeMode.published')
}}</N8nText>
<template v-else-if="promoteOpen">
<N8nText size="small">{{ i18n.baseText('instanceAi.codeMode.promoteBody') }}</N8nText>
<div :class="$style.actions">
<N8nButton size="small" variant="outline" @click="keepInChat">
{{ i18n.baseText('instanceAi.codeMode.keep') }}
</N8nButton>
<N8nButton
size="small"
data-test-id="instance-ai-code-mode-publish"
@click="publishDraft"
>
{{ i18n.baseText('instanceAi.codeMode.publish') }}
</N8nButton>
</div>
</template>
<div v-else :class="$style.actions">
<N8nButton size="small" variant="outline" @click="keepInChat">
{{ i18n.baseText('instanceAi.codeMode.keep') }}
</N8nButton>
<N8nButton
size="small"
data-test-id="instance-ai-code-mode-promote"
@click="promoteOpen = true"
>
{{ i18n.baseText('instanceAi.codeMode.promote') }}
</N8nButton>
</div>
</footer>
</aside>
</template>
<style lang="scss" module>
.panel {
display: flex;
flex-direction: column;
width: 100%;
height: 100%;
min-width: 0;
background: var(--color--background--light-2);
border-left: var(--border);
}
.header,
.toolbar,
.footer {
display: flex;
align-items: center;
gap: var(--spacing--xs);
padding: var(--spacing--xs) var(--spacing--sm);
flex-shrink: 0;
}
.header {
justify-content: space-between;
border-bottom: var(--border);
}
.titleRow {
display: flex;
align-items: center;
gap: var(--spacing--2xs);
min-width: 0;
}
.chip {
border: var(--border);
border-radius: var(--radius--lg);
padding: var(--spacing--5xs) var(--spacing--2xs);
font-size: var(--font-size--2xs);
color: var(--color--text--tint-1);
}
.toolbar {
border-bottom: var(--border);
}
.tab {
border: none;
background: transparent;
color: var(--color--text--tint-1);
padding: var(--spacing--4xs) var(--spacing--2xs);
border-radius: var(--radius--sm);
cursor: pointer;
}
.tabActive {
background: var(--color--foreground--tint-2);
color: var(--color--text);
}
.body {
flex: 1;
min-height: 0;
display: flex;
}
.preview {
flex: 1;
width: 100%;
height: 100%;
border: none;
background: var(--color--background--light-3);
}
.codeLayout {
display: flex;
flex: 1;
min-width: 0;
min-height: 0;
}
.files {
display: flex;
flex-direction: column;
gap: var(--spacing--4xs);
width: var(--spacing--4xl);
padding: var(--spacing--xs);
border-right: var(--border);
flex-shrink: 0;
}
.file {
text-align: left;
border: none;
background: transparent;
padding: var(--spacing--4xs) var(--spacing--2xs);
border-radius: var(--radius--sm);
cursor: pointer;
color: var(--color--text);
}
.fileActive {
background: var(--color--foreground--tint-2);
}
.source {
flex: 1;
margin: 0;
padding: var(--spacing--sm);
overflow: auto;
font-size: var(--font-size--2xs);
line-height: var(--line-height--sm);
white-space: pre-wrap;
}
.footer {
border-top: var(--border);
justify-content: space-between;
flex-wrap: wrap;
}
.actions {
display: flex;
gap: var(--spacing--2xs);
margin-left: auto;
}
</style>

View File

@ -0,0 +1,103 @@
<script setup lang="ts">
import { N8nButton, N8nCallout, N8nText } from '@n8n/design-system';
import { useI18n } from '@n8n/i18n';
const emit = defineEmits<{ 'build-website': [] }>();
const i18n = useI18n();
const cards = [
{
titleKey: 'instanceAi.projectHome.agents.title' as const,
bodyKey: 'instanceAi.projectHome.agents.body' as const,
},
{
titleKey: 'instanceAi.projectHome.approvals.title' as const,
bodyKey: 'instanceAi.projectHome.approvals.body' as const,
},
{
titleKey: 'instanceAi.projectHome.people.title' as const,
bodyKey: 'instanceAi.projectHome.people.body' as const,
},
];
</script>
<template>
<section :class="$style.home" data-test-id="instance-ai-project-home">
<div :class="$style.heading">
<div :class="$style.titles">
<N8nText tag="h2" size="large" bold>{{
i18n.baseText('instanceAi.projectHome.title')
}}</N8nText>
<N8nText size="small" color="text-light">{{
i18n.baseText('instanceAi.projectHome.lead')
}}</N8nText>
</div>
<N8nButton
size="small"
icon="code"
data-test-id="instance-ai-build-website"
@click="emit('build-website')"
>
{{ i18n.baseText('instanceAi.projectHome.buildWebsite') }}
</N8nButton>
</div>
<N8nCallout theme="info" slim>
{{ i18n.baseText('instanceAi.projectHome.mcp') }}
</N8nCallout>
<div :class="$style.cards">
<article v-for="card in cards" :key="card.titleKey" :class="$style.card">
<N8nText tag="h3" size="small" bold>{{ i18n.baseText(card.titleKey) }}</N8nText>
<N8nText size="small" color="text-light">{{ i18n.baseText(card.bodyKey) }}</N8nText>
</article>
</div>
</section>
</template>
<style lang="scss" module>
.home {
width: 100%;
/* Matches the composer width. No spacing token is 680px. */
max-width: 680px;
display: flex;
flex-direction: column;
gap: var(--spacing--sm);
}
.heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--spacing--sm);
}
.titles {
display: flex;
flex-direction: column;
gap: var(--spacing--4xs);
min-width: 0;
}
.cards {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--spacing--xs);
}
.card {
display: flex;
flex-direction: column;
gap: var(--spacing--4xs);
padding: var(--spacing--sm);
border: var(--border);
border-radius: var(--radius--lg);
background: var(--color--background--light-3);
min-width: 0;
}
@media (max-width: 50rem) {
.cards {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -109,7 +109,6 @@ const pendingWorkflowAttachmentKey = (threadId: string) =>
const pendingRedirectLandingKey = (threadId: string) =>
`n8n-instance-ai-redirect-landing:${threadId}`;
export function buildInstanceAiCredentialHandoffContext(
credential: InstanceAiCredentialContext,
): InstanceAiHandoffContext {

View File

@ -0,0 +1,18 @@
import { afterEach, describe, expect, it } from 'vitest';
import { readHomeMode, writeHomeMode } from './homeMode';
describe('assistant home mode', () => {
afterEach(() => {
localStorage.clear();
});
it('starts in the streamlined user mode', () => {
expect(readHomeMode()).toBe('user');
});
it('remembers power mode', () => {
writeHomeMode('power');
expect(readHomeMode()).toBe('power');
});
});

View File

@ -0,0 +1,20 @@
export type AssistantHomeMode = 'user' | 'power';
const STORAGE_KEY = 'n8n-instance-ai-home-mode';
/** User mode is the default. Power is the only stored choice. */
export function readHomeMode(): AssistantHomeMode {
try {
return localStorage.getItem(STORAGE_KEY) === 'power' ? 'power' : 'user';
} catch {
return 'user';
}
}
export function writeHomeMode(mode: AssistantHomeMode): void {
try {
localStorage.setItem(STORAGE_KEY, mode);
} catch {
// Private browsing can reject storage. The choice still applies in this view.
}
}

View File

@ -165,6 +165,8 @@ export interface AgentsChatMessage {
interactives?: InteractivePayload[];
interactive?: InteractivePayload;
attachments?: ChatMessageAttachment[];
/** Set when the turn failed for a known reason, such as an exhausted credit pool. */
errorCode?: string;
/** Persisted agent execution id for this turn (history parse or live SSE `done`). */
executionId?: string;
/**

View File

@ -38,7 +38,7 @@
"test:evals:smoke": "playwright test --project=eval:smoke",
"test:unit": "vitest run",
"test:harness": "vitest run --config vitest.harness.config.ts",
"distribution:count": "pnpm turbo run build --filter=@n8n/playwright-janitor --filter=@n8n/workflow-sdk... 1>&2 && node scripts/count-test-distribution.mjs",
"distribution:count": "pnpm turbo run build --filter=@n8n/playwright-janitor --filter=@n8n/workflow-sdk... --filter=@n8n/api-types... 1>&2 && node scripts/count-test-distribution.mjs",
"coverage:shard": "node scripts/run-coverage-shard.mjs",
"coverage:emit-shard": "tsx scripts/emit-shard-coverage.ts",
"coverage:emit-spec-lcovs": "tsx scripts/emit-spec-lcovs.ts",

View File

@ -94,6 +94,66 @@ export class ExperienceModes {
return this.page.getByTestId('automation-proposal-resolved-status');
}
/** The line that names where the automation runs. */
getProposalPlace(): Locator {
return this.page.getByTestId('automation-proposal-place');
}
/** The note that a local automation runs only while this computer is on. */
getProposalCaveat(): Locator {
return this.page.getByTestId('automation-proposal-caveat');
}
/** "Change" on the place line. Power mode shows it when the card offers more than one place. */
getProposalChangeTarget(): Locator {
return this.page.getByTestId('automation-proposal-change-target');
}
/** One place in the open "Where should it run?" menu, by the start of its name. */
getProposalTargetOption(name: RegExp): Locator {
return this.page.getByRole('menuitemcheckbox', { name });
}
/** What the linked instance needs before the workflow goes there. */
getProposalPreflight(): Locator {
return this.page.getByTestId('automation-proposal-preflight');
}
/** The line that names credentials the linked instance does not have. */
getProposalPreflightSetUp(): Locator {
return this.page.getByTestId('automation-proposal-preflight-set-up');
}
/** "Set up in {place}" on the preflight notice. It opens the credentials of that instance. */
getProposalPreflightSetUpLink(): Locator {
return this.page.getByTestId('automation-proposal-preflight-set-up-link');
}
/** The line that says the workflow cannot go to the linked instance. */
getProposalPreflightBlocked(): Locator {
return this.page.getByTestId('automation-proposal-preflight-blocked');
}
/** "Keep it on this computer" on the blocked notice. */
getProposalKeepHere(): Locator {
return this.page.getByTestId('automation-proposal-preflight-keep-here');
}
/** "Save, but leave it off" on the proposal card. */
getProposalSaveButton(): Locator {
return this.page.getByTestId('automation-proposal-save');
}
/** "Not now" on the proposal card. */
getProposalNotNowButton(): Locator {
return this.page.getByTestId('automation-proposal-not-now');
}
/** The link that opens the copy in the linked instance. */
getProposalOpenRemote(): Locator {
return this.page.getByTestId('automation-proposal-open-remote');
}
/** One group of the chat list in Power mode. */
getChatGroup(group: 'needs-you' | 'working' | 'ready' | 'done'): Locator {
return this.page.getByTestId(`assistant-chat-group-${group}`);

View File

@ -26,7 +26,7 @@
* node distribute-tests.mjs <shards> <index> # Specs for a single shard
*/
import { execFileSync } from 'child_process';
import { execFileSync, spawnSync } from 'child_process';
import { mkdtempSync, readFileSync, writeFileSync, rmSync } from 'node:fs';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
@ -194,23 +194,31 @@ function generateDistributionGroups(project, grepInvert) {
const temp = mkdtempSync(path.join(tmpdir(), 'distribution-groups-'));
const reportPath = path.join(temp, 'playwright-profiles.json');
const groupsPath = path.join(temp, 'groups.json');
execFileSync(
process.execPath,
[
PLAYWRIGHT_CLI,
'test',
'--list',
`--project=${project}`,
'--workers=1',
`--reporter=${DISTRIBUTION_REPORTER}`,
...(grepInvert ? [`--grep-invert=${grepInvert}`] : []),
],
{
cwd: PLAYWRIGHT_DIR,
env: { ...process.env, DISTRIBUTION_COUNTER_OUTPUT: reportPath },
stdio: ['ignore', 'ignore', 'inherit'],
},
);
const args = [
PLAYWRIGHT_CLI,
'test',
'--list',
`--project=${project}`,
'--workers=1',
`--reporter=${DISTRIBUTION_REPORTER}`,
...(grepInvert ? [`--grep-invert=${grepInvert}`] : []),
];
// The custom reporter replaces the list reporter, so a load error stays on
// the child stderr. Print that stderr. Otherwise the parent only sees exit 1.
const result = spawnSync(process.execPath, args, {
cwd: PLAYWRIGHT_DIR,
env: { ...process.env, DISTRIBUTION_COUNTER_OUTPUT: reportPath },
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
maxBuffer: 64 * 1024 * 1024,
});
if (result.stderr) process.stderr.write(result.stderr);
if (result.error) throw result.error;
if (result.status !== 0) {
if (result.stdout) process.stderr.write(result.stdout);
const signal = result.signal ? ` signal ${result.signal}` : '';
throw new Error(`Command failed (${result.status ?? 'none'}${signal}): ${args.join(' ')}`);
}
const report = JSON.parse(readFileSync(reportPath, 'utf8'));
const groups = buildDistributionGroups(report);
writeFileSync(groupsPath, JSON.stringify(groups));

View File

@ -1,11 +1,18 @@
/* eslint-disable import-x/no-default-export -- Playwright loads a default reporter export. */
import type { FullConfig, Reporter, Suite, TestCase } from '@playwright/test/reporter';
import type { FullConfig, Reporter, Suite, TestCase, TestError } from '@playwright/test/reporter';
import { writeFileSync } from 'node:fs';
import { relative, sep } from 'node:path';
function listedTestLabel(test: TestCase): string {
const title = test.titlePath().join(' › ');
return `${test.location.file}:${test.location.line} ${title}`;
}
function workerHash(test: TestCase): string {
const value: unknown = Reflect.get(test, '_workerHash');
if (typeof value !== 'string' || value.length === 0) {
// The runner hides a reporter exception. Print the test before the throw.
console.error(`Playwright did not expose a worker hash for ${listedTestLabel(test)}`);
throw new Error('Playwright did not expose a worker hash for a listed test');
}
return value;
@ -14,6 +21,7 @@ function workerHash(test: TestCase): string {
function poolDigest(test: TestCase): string {
const value: unknown = Reflect.get(test, '_poolDigest');
if (typeof value !== 'string' || value.length === 0) {
console.error(`Playwright did not expose a fixture-pool digest for ${listedTestLabel(test)}`);
throw new Error('Playwright did not expose a fixture-pool digest for a listed test');
}
return value;
@ -24,6 +32,13 @@ function relativeSpec(file: string): string {
}
export default class DistributionCounterReporter implements Reporter {
// Playwright hides a reporter exception and does not print a load error
// when this reporter replaces the list reporter. Write the error here.
onError(error: TestError): void {
const text = error.stack ?? error.message;
if (text) console.error(text);
}
onBegin(_config: FullConfig, suite: Suite): void {
const output = process.env.DISTRIBUTION_COUNTER_OUTPUT;
if (!output) throw new Error('DISTRIBUTION_COUNTER_OUTPUT is required');

View File

@ -114,7 +114,9 @@ export function baseInstanceEnv(instance, userFolder) {
/** Assistant variables for "This computer". The model is the scripted LLM. */
export function localAssistantEnv({ llmPort, sandboxPort }) {
return {
N8N_ENABLED_MODULES: 'instance-ai',
// instance-ai is a default module. linked-instances is off until it is named here,
// and the automation card offers the cloud only while it is on.
N8N_ENABLED_MODULES: 'instance-ai,linked-instances',
N8N_INSTANCE_AI_MODEL: 'anthropic/claude-scripted',
// Keep `/v1`: the proxy code path does not add it.
N8N_INSTANCE_AI_MODEL_URL: `http://127.0.0.1:${llmPort}/v1`,

View File

@ -160,7 +160,7 @@ describe('localAssistantEnv', () => {
const env = localAssistantEnv({ llmPort: 4010, sandboxPort: 4011 });
expect(env).toMatchObject({
N8N_ENABLED_MODULES: 'instance-ai',
N8N_ENABLED_MODULES: 'instance-ai,linked-instances',
N8N_INSTANCE_AI_MODEL: 'anthropic/claude-scripted',
N8N_INSTANCE_AI_MODEL_URL: 'http://127.0.0.1:4010/v1',
N8N_INSTANCE_AI_MODEL_API_KEY: 'scripted',

View File

@ -5,6 +5,7 @@ import type {
InstanceAiPermissions,
InstanceAiAdminSettingsUpdateRequest,
InstanceAiThreadInfo,
LinkedInstanceSummary,
} from '@n8n/api-types';
import { request, type APIRequestContext } from '@playwright/test';
import type { IWorkflowSettings } from 'n8n-workflow';
@ -806,6 +807,25 @@ export class ApiHelpers {
}
}
/**
* Link another n8n instance for the signed-in user. The server checks the
* address with the token before it stores the link. The response has no token.
*/
async linkInstance(input: {
name: string;
url: string;
token: string;
}): Promise<LinkedInstanceSummary> {
const response = await this.request.post('/rest/linked-instances', { data: input });
if (!response.ok()) {
throw new TestError(`Failed to link instance: ${response.status()} ${await response.text()}`);
}
const result = await response.json();
return result.data ?? result;
}
// ===== PRIVATE METHODS =====
private async loginAndSetCookies(

View File

@ -2,15 +2,17 @@ import type { APIResponse } from '@playwright/test';
import type { ApiHelpers } from './api-helper';
/** A binary file attached to a chat message, as the chat endpoint expects it. */
/** A binary file attached to an Assistant chat message, as the agents chat endpoint expects it. */
export interface InstanceAiFileAttachmentPayload {
type: 'file';
/** Base64-encoded contents. */
data: string;
mimeType: string;
fileName: string;
}
/** The code-defined n8n Assistant agent. */
const ASSISTANT_AGENT_ID = 'n8n-assistant';
/**
* Helper for driving Instance AI endpoints directly, bypassing the editor UI.
*/
@ -25,10 +27,13 @@ export class InstanceAiApiHelper {
* non-2xx status is the expected outcome rather than an error.
*/
async sendMessageResponse(
threadId: string,
projectId: string,
payload: { message?: string; attachments?: InstanceAiFileAttachmentPayload[] },
): Promise<APIResponse> {
return await this.api.request.post(`/rest/instance-ai/chat/${threadId}`, { data: payload });
return await this.api.request.post(
`/rest/projects/${projectId}/agents/v2/${ASSISTANT_AGENT_ID}/chat`,
{ data: payload },
);
}
/**

View File

@ -90,7 +90,8 @@ Both instances: `E2E_TESTS=true`, `N8N_LISTEN_ADDRESS=127.0.0.1`,
"This computer" also gets:
- `N8N_ENABLED_MODULES=instance-ai`.
- `N8N_ENABLED_MODULES=instance-ai,linked-instances`. The automation card
offers the cloud only while the linked-instances module is on.
- `N8N_INSTANCE_AI_MODEL=anthropic/claude-scripted`,
`N8N_INSTANCE_AI_MODEL_URL` (the scripted LLM, with `/v1`) and
`N8N_INSTANCE_AI_MODEL_API_KEY=scripted`.

View File

@ -0,0 +1,572 @@
import { nanoid } from 'nanoid';
import type { IWorkflowBase } from 'n8n-workflow';
import type { n8nPage } from '../../../pages/n8nPage';
import type { ApiHelpers } from '../../../services/api-helper';
import type { ScriptInput } from '../../../services/scripted-llm/scripted-llm.types';
import { TestError } from '../../../Types';
import { expect, requireLinkedInstances, test, type AssistantRun } from './fixtures';
type Mode = 'simple' | 'power';
// The system prompt of the Assistant agent starts with this text. The scripted rules use it
// to skip title and memory calls.
const AGENT_PROMPT = 'n8n Instance Agent';
const CLOUD_NAME = 'Cloud';
const PROPOSAL_REPLY = 'I can keep this and turn it on.';
const PROPOSAL_TITLE = 'Morning digest';
const PROPOSAL_ASK = 'Turn it into an automation';
const CREDENTIAL_NAME = 'Stripe key';
// Chats wait for the scripted model, the card and the copy on the other instance.
const STEP_TIMEOUT_MS = 60_000;
type NamedWorkflow = { id: string; name: string; active: boolean };
requireLinkedInstances();
test.describe(
'Automation proposals on a linked instance',
{ annotation: [{ type: 'owner', description: 'instanceAI' }] },
() => {
test.describe.configure({ mode: 'serial' });
test('Power mode recommends the cloud, and Turn it on copies the workflow there and turns it on', async ({
n8n,
cloudApi,
cloudUrl,
startLlm,
startAssistantRun,
}) => {
const workflowName = `Cloud digest ${nanoid(8)}`;
await linkCloud(n8n.api, cloudApi, cloudUrl);
const workflow = await n8n.api.workflows.createWorkflow(scheduleWorkflow(workflowName));
await openProposal(n8n, startLlm, startAssistantRun, workflow.id, 'power');
await expect(n8n.experienceModes.getProposalPlace()).toContainText(CLOUD_NAME);
await expect(n8n.experienceModes.getProposalPlace()).toContainText(
'it keeps going when this computer is off',
);
await expect(n8n.experienceModes.getProposalChangeTarget()).toBeVisible();
await expect(n8n.experienceModes.getProposalTurnOnButton()).toBeEnabled({
timeout: STEP_TIMEOUT_MS,
});
await n8n.experienceModes.getProposalTurnOnButton().click();
await expectResolvedIn(n8n, 'on', CLOUD_NAME);
const remote = n8n.experienceModes.getProposalOpenRemote();
await expect(remote).toBeVisible();
await expect(remote).toHaveAttribute(
'href',
new RegExp(`^${escapeRegExp(cloudUrl)}/workflow/`),
);
await expect(remote).toHaveAttribute('target', '_blank');
const copy = await waitForWorkflow(cloudApi, workflowName);
expect(copy.active).toBe(true);
await expect
.poll(async () => (await n8n.api.workflows.getWorkflow(workflow.id)).active)
.toBe(false);
});
test('Power mode can keep the automation on this computer instead', async ({
n8n,
cloudApi,
cloudUrl,
startLlm,
startAssistantRun,
}) => {
const workflowName = `Local digest ${nanoid(8)}`;
await linkCloud(n8n.api, cloudApi, cloudUrl);
const workflow = await n8n.api.workflows.createWorkflow(scheduleWorkflow(workflowName));
await openProposal(n8n, startLlm, startAssistantRun, workflow.id, 'power');
await expect(n8n.experienceModes.getProposalTurnOnButton()).toBeEnabled({
timeout: STEP_TIMEOUT_MS,
});
await n8n.experienceModes.getProposalChangeTarget().click();
await n8n.experienceModes.getProposalTargetOption(/^This computer/).click();
await expect(n8n.experienceModes.getProposalPlace()).toContainText('This computer');
await expect(n8n.experienceModes.getProposalCaveat()).toContainText(
'Only runs while this computer is on.',
);
await expect(n8n.experienceModes.getProposalPreflight()).toBeHidden();
await n8n.experienceModes.getProposalTurnOnButton().click();
await expectResolvedIn(n8n, 'on', 'This computer');
await expect
.poll(async () => (await n8n.api.workflows.getWorkflow(workflow.id)).active, {
timeout: STEP_TIMEOUT_MS,
})
.toBe(true);
expect(await findWorkflow(cloudApi, workflowName)).toBeUndefined();
});
test('a missing credential holds Turn it on, and Save copies the workflow switched off', async ({
n8n,
cloudApi,
cloudUrl,
startLlm,
startAssistantRun,
}) => {
const workflowName = `Credential digest ${nanoid(8)}`;
await linkCloud(n8n.api, cloudApi, cloudUrl);
const credential = await n8n.api.credentials.createCredential({
name: CREDENTIAL_NAME,
type: 'httpHeaderAuth',
data: { name: 'Authorization', value: 'secret' },
});
const workflow = await n8n.api.workflows.createWorkflow(
credentialWorkflow(workflowName, credential.id),
);
await openProposal(n8n, startLlm, startAssistantRun, workflow.id, 'power');
await expect(n8n.experienceModes.getProposalPreflightSetUp()).toContainText(CREDENTIAL_NAME, {
timeout: STEP_TIMEOUT_MS,
});
await expect(n8n.experienceModes.getProposalPreflightSetUp()).toContainText(CLOUD_NAME);
const setUp = n8n.experienceModes.getProposalPreflightSetUpLink();
await expect(setUp).toBeVisible();
await expect(setUp).toHaveAttribute(
'href',
`${cloudUrl.replace(/\/+$/, '')}/home/credentials`,
);
await expect(n8n.experienceModes.getProposalTurnOnButton()).toBeDisabled();
await expect(n8n.experienceModes.getProposalSaveButton()).toBeEnabled();
await expect(n8n.experienceModes.getProposalNotNowButton()).toBeEnabled();
await n8n.experienceModes.getProposalSaveButton().click();
await expectResolvedIn(n8n, 'saved', CLOUD_NAME);
const copy = await waitForWorkflow(cloudApi, workflowName);
expect(copy.active).toBe(false);
expect((await n8n.api.workflows.getWorkflow(workflow.id)).active).toBe(false);
});
test('Not now leaves the workflow off on both instances', async ({
n8n,
cloudApi,
cloudUrl,
startLlm,
startAssistantRun,
}) => {
const workflowName = `Declined digest ${nanoid(8)}`;
await linkCloud(n8n.api, cloudApi, cloudUrl);
const workflow = await n8n.api.workflows.createWorkflow(scheduleWorkflow(workflowName));
await openProposal(n8n, startLlm, startAssistantRun, workflow.id, 'power');
await expect(n8n.experienceModes.getProposalNotNowButton()).toBeEnabled({
timeout: STEP_TIMEOUT_MS,
});
await n8n.experienceModes.getProposalNotNowButton().click();
await expectResolved(n8n, 'declined');
expect((await n8n.api.workflows.getWorkflow(workflow.id)).active).toBe(false);
expect(await findWorkflow(cloudApi, workflowName)).toBeUndefined();
});
test('Simple mode shows only the cloud recommendation and still turns the copy on there', async ({
n8n,
cloudApi,
cloudUrl,
startLlm,
startAssistantRun,
}) => {
const workflowName = `Simple digest ${nanoid(8)}`;
await linkCloud(n8n.api, cloudApi, cloudUrl);
const workflow = await n8n.api.workflows.createWorkflow(scheduleWorkflow(workflowName));
await openProposal(n8n, startLlm, startAssistantRun, workflow.id, 'simple');
await expect(n8n.experienceModes.getProposalChangeTarget()).toBeHidden();
await expect(n8n.experienceModes.getProposalPlace()).toContainText(CLOUD_NAME);
await expect(n8n.experienceModes.getProposalPlace()).toContainText(
'it keeps going when this computer is off',
);
await expect(n8n.experienceModes.getProposalTurnOnButton()).toBeEnabled({
timeout: STEP_TIMEOUT_MS,
});
await n8n.experienceModes.getProposalTurnOnButton().click();
await expectResolvedIn(n8n, 'on', CLOUD_NAME);
const copy = await waitForWorkflow(cloudApi, workflowName);
expect(copy.active).toBe(true);
});
test('a command on this computer is recommended here, and Power mode can still choose the cloud', async ({
n8n,
cloudApi,
cloudUrl,
startLlm,
startAssistantRun,
}) => {
const workflowName = `Local command ${nanoid(8)}`;
await linkCloud(n8n.api, cloudApi, cloudUrl);
const workflow = await n8n.api.workflows.createWorkflow(commandWorkflow(workflowName));
await openProposal(n8n, startLlm, startAssistantRun, workflow.id, 'power');
await expect(n8n.experienceModes.getProposalPlace()).toContainText('This computer');
await expect(n8n.experienceModes.getProposalPlace()).toContainText(
'it runs commands on this computer',
);
await expect(n8n.experienceModes.getProposalCaveat()).toBeVisible();
await expect(n8n.experienceModes.getProposalPreflight()).toBeHidden();
await n8n.experienceModes.getProposalChangeTarget().click();
await n8n.experienceModes.getProposalTargetOption(/^Cloud/).click();
await expect(n8n.experienceModes.getProposalPlace()).toContainText(`Runs on ${CLOUD_NAME}`);
await expect(n8n.experienceModes.getProposalTurnOnButton()).toBeEnabled({
timeout: STEP_TIMEOUT_MS,
});
await n8n.experienceModes.getProposalChangeTarget().click();
await n8n.experienceModes.getProposalTargetOption(/^This computer/).click();
await expect(n8n.experienceModes.getProposalPlace()).toContainText('This computer');
await expect(n8n.experienceModes.getProposalCaveat()).toBeVisible();
await expect(n8n.experienceModes.getProposalPreflight()).toBeHidden();
await expect(n8n.experienceModes.getProposalTurnOnButton()).toBeEnabled();
// Choosing a place does not copy the workflow. The card is still open.
expect(await findWorkflow(cloudApi, workflowName)).toBeUndefined();
expect((await n8n.api.workflows.getWorkflow(workflow.id)).active).toBe(false);
});
test('a workflow that calls another workflow by ID offers only this computer', async ({
n8n,
cloudApi,
cloudUrl,
startLlm,
startAssistantRun,
}) => {
const workflowName = `Parent digest ${nanoid(8)}`;
await linkCloud(n8n.api, cloudApi, cloudUrl);
// A parent turns on only when the workflow it calls is already on.
const child = await n8n.api.workflows.createWorkflow(scheduleWorkflow(`Child ${nanoid(8)}`));
await publishWorkflow(n8n.api, child.id);
const workflow = await n8n.api.workflows.createWorkflow(
callingWorkflow(workflowName, child.id),
);
await openProposal(n8n, startLlm, startAssistantRun, workflow.id, 'power');
await expect(n8n.experienceModes.getProposalChangeTarget()).toBeHidden();
await expect(n8n.experienceModes.getProposalPlace()).toContainText('This computer');
await expect(n8n.experienceModes.getProposalPreflight()).toBeHidden();
await n8n.experienceModes.getProposalTurnOnButton().click();
await expectResolvedIn(n8n, 'on', 'This computer');
await expect
.poll(async () => (await n8n.api.workflows.getWorkflow(workflow.id)).active, {
timeout: STEP_TIMEOUT_MS,
})
.toBe(true);
expect(await findWorkflow(cloudApi, workflowName)).toBeUndefined();
});
test('a call added after the card keeps the workflow here until the user chooses this computer', async ({
n8n,
cloudApi,
cloudUrl,
startLlm,
startAssistantRun,
}) => {
const workflowName = `Changed digest ${nanoid(8)}`;
await linkCloud(n8n.api, cloudApi, cloudUrl);
const child = await n8n.api.workflows.createWorkflow(
manualWorkflow(`Later child ${nanoid(8)}`),
);
const workflow = await n8n.api.workflows.createWorkflow(scheduleWorkflow(workflowName));
const chatId = await propose(n8n, startLlm, startAssistantRun, workflow.id);
await replaceWorkflow(n8n.api, workflow.id, callingWorkflow(workflowName, child.id));
await n8n.api.users.setExperienceMode('power');
await n8n.start.fromInstanceAiThread(chatId);
await expect(n8n.experienceModes.getProposalCard()).toBeVisible({ timeout: STEP_TIMEOUT_MS });
await expect(n8n.experienceModes.getProposalPreflightBlocked()).toContainText(
"It can't go to Cloud, because it calls other workflows by ID.",
{ timeout: STEP_TIMEOUT_MS },
);
await expect(n8n.experienceModes.getProposalTurnOnButton()).toBeDisabled();
await expect(n8n.experienceModes.getProposalSaveButton()).toBeDisabled();
await expect(n8n.experienceModes.getProposalNotNowButton()).toBeEnabled();
await n8n.experienceModes.getProposalKeepHere().click();
await expect(n8n.experienceModes.getProposalPlace()).toContainText('This computer');
await expect(n8n.experienceModes.getProposalPreflight()).toBeHidden();
await expect(n8n.experienceModes.getProposalTurnOnButton()).toBeEnabled();
});
},
);
/** Turns the workflow on and waits until it is active. */
async function publishWorkflow(api: ApiHelpers, workflowId: string): Promise<void> {
const current = await api.workflows.getWorkflow(workflowId);
const versionId = current.versionId ?? '';
expect(versionId).not.toEqual('');
await api.workflows.activate(workflowId, versionId);
await expect
.poll(async () => (await api.workflows.getWorkflow(workflowId)).active, {
timeout: STEP_TIMEOUT_MS,
})
.toBe(true);
}
/** Saves a new version of the workflow. The card still shows the version it proposed. */
async function replaceWorkflow(
api: ApiHelpers,
workflowId: string,
workflow: Partial<IWorkflowBase>,
): Promise<void> {
const current = await api.workflows.getWorkflow(workflowId);
const versionId = current.versionId ?? '';
expect(versionId).not.toEqual('');
await api.workflows.update(workflowId, versionId, workflow);
}
/** Turns on MCP on Cloud and stores the link on this computer. */
async function linkCloud(api: ApiHelpers, cloudApi: ApiHelpers, cloudUrl: string): Promise<void> {
await cloudApi.setMcpAccess(true);
const { apiKey } = await cloudApi.rotateMcpApiKey();
const link = await api.linkInstance({ name: CLOUD_NAME, url: cloudUrl, token: apiKey });
expect(link.status).toBe('online');
}
/** Asks for the proposal and returns the chat id. The page is not open yet. */
async function propose(
n8n: n8nPage,
startLlm: (script: ScriptInput) => Promise<unknown>,
startAssistantRun: (threadId: string, message: string) => Promise<AssistantRun>,
workflowId: string,
): Promise<string> {
const chat = await n8n.api.createInstanceAiThread();
await n8n.api.renameInstanceAiThread(chat.id, `Automation ${nanoid(6)}`);
await startLlm(proposeScript(workflowId));
const run = await startAssistantRun(chat.id, PROPOSAL_ASK);
await expect
.poll(() => suspensionsOf(run).length, { timeout: STEP_TIMEOUT_MS })
.toBeGreaterThan(0);
run.disconnect();
return chat.id;
}
/** Proposes the workflow, then opens the chat in the given mode so the card is on screen. */
async function openProposal(
n8n: n8nPage,
startLlm: (script: ScriptInput) => Promise<unknown>,
startAssistantRun: (threadId: string, message: string) => Promise<AssistantRun>,
workflowId: string,
mode: Mode,
): Promise<void> {
const chatId = await propose(n8n, startLlm, startAssistantRun, workflowId);
await n8n.api.users.setExperienceMode(mode);
await n8n.start.fromInstanceAiThread(chatId);
await expect(n8n.instanceAi.getPanelText(PROPOSAL_REPLY)).toBeVisible({
timeout: STEP_TIMEOUT_MS,
});
await expect(n8n.experienceModes.getProposalCard()).toBeVisible({ timeout: STEP_TIMEOUT_MS });
}
/** The answered card shows this outcome. */
async function expectResolved(n8n: n8nPage, status: string): Promise<void> {
await expect(n8n.experienceModes.getProposalResolved()).toBeVisible({ timeout: STEP_TIMEOUT_MS });
await expect(n8n.experienceModes.getProposalCard()).toBeHidden();
await expect(n8n.experienceModes.getProposalResolvedStatus()).toHaveAttribute(
'data-status',
status,
{
timeout: STEP_TIMEOUT_MS,
},
);
}
/** The answered card shows this outcome and names the place. */
async function expectResolvedIn(n8n: n8nPage, status: string, place: string): Promise<void> {
await expectResolved(n8n, status);
await expect(n8n.experienceModes.getProposalResolvedStatus()).toContainText(place);
}
function proposeScript(workflowId: string): ScriptInput {
return {
rules: [
{
id: 'propose-automation',
when: {
systemIncludes: AGENT_PROMPT,
userText: PROPOSAL_ASK,
toolAvailable: 'propose_automation',
},
reply: {
text: PROPOSAL_REPLY,
toolCalls: [
{
name: 'propose_automation',
input: {
workflowId,
title: PROPOSAL_TITLE,
why: ['It should run every morning.'],
},
},
],
},
},
],
fallback: { text: 'Scripted fallback.' },
};
}
function suspensionsOf(run: AssistantRun) {
return run.events.flatMap((event) =>
event.type === 'tool-call-suspended' ? [event.payload] : [],
);
}
async function findWorkflow(api: ApiHelpers, name: string): Promise<NamedWorkflow | undefined> {
const workflows: NamedWorkflow[] = await api.workflows.getWorkflows();
return workflows.find((workflow) => workflow.name === name);
}
/** Waits until the other instance lists the workflow, then returns it. */
async function waitForWorkflow(api: ApiHelpers, name: string): Promise<NamedWorkflow> {
let found: NamedWorkflow | undefined;
await expect
.poll(
async () => {
found = await findWorkflow(api, name);
return found !== undefined;
},
{ timeout: STEP_TIMEOUT_MS },
)
.toBe(true);
if (!found) throw new TestError(`No workflow named "${name}"`);
return found;
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function at(x: number, y: number): [number, number] {
return [x, y];
}
function scheduleNode() {
return {
id: 'schedule',
name: 'Schedule Trigger',
type: 'n8n-nodes-base.scheduleTrigger',
typeVersion: 1.2,
position: at(0, 0),
parameters: { rule: { interval: [{ field: 'days', daysInterval: 1, triggerAtHour: 8 }] } },
};
}
function scheduleWorkflow(name: string): Partial<IWorkflowBase> {
return {
name,
active: false,
nodes: [
scheduleNode(),
{
id: 'noop',
name: 'No Operation',
type: 'n8n-nodes-base.noOp',
typeVersion: 1,
position: at(240, 0),
parameters: {},
},
],
connections: {
'Schedule Trigger': { main: [[{ node: 'No Operation', type: 'main', index: 0 }]] },
},
settings: {},
};
}
function credentialWorkflow(name: string, credentialId: string): Partial<IWorkflowBase> {
return {
name,
active: false,
nodes: [
scheduleNode(),
{
id: 'http',
name: 'Charge',
type: 'n8n-nodes-base.httpRequest',
typeVersion: 4.2,
position: at(240, 0),
parameters: {
url: 'https://example.test/charge',
authentication: 'genericCredentialType',
genericAuthType: 'httpHeaderAuth',
},
credentials: { httpHeaderAuth: { id: credentialId, name: CREDENTIAL_NAME } },
},
],
connections: {
'Schedule Trigger': { main: [[{ node: 'Charge', type: 'main', index: 0 }]] },
},
settings: {},
};
}
function commandWorkflow(name: string): Partial<IWorkflowBase> {
return {
name,
active: false,
nodes: [
scheduleNode(),
{
id: 'command',
name: 'Run a command',
type: 'n8n-nodes-base.executeCommand',
typeVersion: 1,
position: at(240, 0),
parameters: { command: 'echo hello' },
},
],
connections: {
'Schedule Trigger': { main: [[{ node: 'Run a command', type: 'main', index: 0 }]] },
},
settings: {},
};
}
function manualWorkflow(name: string): Partial<IWorkflowBase> {
return {
name,
active: false,
nodes: [
{
id: 'manual',
name: 'Manual Trigger',
type: 'n8n-nodes-base.manualTrigger',
typeVersion: 1,
position: at(0, 0),
parameters: {},
},
],
connections: {},
settings: {},
};
}
function callingWorkflow(name: string, childId: string): Partial<IWorkflowBase> {
return {
name,
active: false,
nodes: [
scheduleNode(),
{
id: 'call',
name: 'Run the report',
type: 'n8n-nodes-base.executeWorkflow',
typeVersion: 1.2,
position: at(240, 0),
parameters: {
source: 'database',
workflowId: { __rl: true, mode: 'id', value: childId },
},
},
],
connections: {
'Schedule Trigger': { main: [[{ node: 'Run the report', type: 'main', index: 0 }]] },
},
settings: {},
};
}

View File

@ -2,6 +2,8 @@ import fs from 'fs/promises';
import os from 'os';
import path from 'path';
import { MAX_AGENT_CHAT_ATTACHMENT_BASE64_LENGTH } from '@n8n/api-types';
import { test, expect, instanceAiTestConfig, SKIP_PROXY_SETUP_ANNOTATION } from './fixtures';
/**
@ -109,14 +111,15 @@ test.describe(
{ annotation: [{ type: SKIP_PROXY_SETUP_ANNOTATION }] },
async ({ n8n }) => {
// The composer check is a convenience; the backend is authoritative. Post
// straight to the endpoint to prove a scripted client cannot get past it.
const response = await n8n.api.instanceAi.sendMessageResponse(crypto.randomUUID(), {
// straight to the agents chat to prove a scripted client cannot get past it.
const project = await n8n.api.projects.getMyPersonalProject();
const response = await n8n.api.instanceAi.sendMessageResponse(project.id, {
message: 'look at this',
attachments: [
{
type: 'file',
// One byte over the encoded ceiling of 10 MiB.
data: 'A'.repeat(10 * 1024 * 1024 + 1),
// One character over the encoded ceiling. The DTO rejects it
// before the handler stores the file.
data: 'A'.repeat(MAX_AGENT_CHAT_ATTACHMENT_BASE64_LENGTH + 1),
mimeType: 'image/png',
fileName: 'huge.png',
},
@ -125,9 +128,7 @@ test.describe(
expect(response.status()).toBe(400);
const body = await response.text();
// Quotes the limit in the unit the user can act on, and says what to do.
expect(body).toContain('7.5 MB');
expect(body.toLowerCase()).toContain('smaller');
expect(body).toContain('10 MB');
},
);