From dffd0ecc5e38345b66fb21486e4c84c57edbdfa2 Mon Sep 17 00:00:00 2001 From: Michael Drury Date: Fri, 10 Jul 2026 08:59:15 +0100 Subject: [PATCH 01/50] fix(core): New agent UI fix width of dropdown menu (no-changelog) (#33941) --- .../features/agents/__tests__/AgentBuilderHeader.test.ts | 8 +++++++- .../src/features/agents/components/AgentBuilderHeader.vue | 5 +++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts index 9c68b17453c..578a480b16f 100644 --- a/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts +++ b/packages/frontend/editor-ui/src/features/agents/__tests__/AgentBuilderHeader.test.ts @@ -76,7 +76,7 @@ vi.mock('@n8n/design-system', () => ({ N8nActionDropdown: { name: 'ActionDropdown', template: '
', - props: ['items', 'activatorIcon'], + props: ['items', 'activatorIcon', 'extraPopperClass'], emits: ['select'], }, })); @@ -179,6 +179,12 @@ describe('AgentBuilderHeader', () => { expect(action.props('activatorIcon')).toBe('ellipsis'); }); + it('widens the header action menu so labels are readable from the icon trigger', () => { + const wrapper = mountHeader({ headerActions: [{ id: 'delete', label: 'Delete agent' }] }); + const action = wrapper.findComponent({ name: 'ActionDropdown' }); + expect(action.props('extraPopperClass')).toBeTruthy(); + }); + it('hides the action dropdown when no header actions are available', () => { const wrapper = mountHeader({ headerActions: [] }); expect(wrapper.find('[data-testid="agent-header-actions"]').exists()).toBe(false); diff --git a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue index 324c1556cf5..fa5dadb611e 100644 --- a/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue +++ b/packages/frontend/editor-ui/src/features/agents/components/AgentBuilderHeader.vue @@ -246,6 +246,7 @@ const isVersionHistoryDisabled = computed(() => !props.agent?.hasPublishHistory) :items="headerActions" activator-icon="ellipsis" activator-size="medium" + :extra-popper-class="$style.headerActionsMenu" data-testid="agent-header-actions" @select="(item: string) => emit('header-action', item)" /> @@ -339,4 +340,8 @@ const isVersionHistoryDisabled = computed(() => !props.agent?.hasPublishHistory) .activeButton { background-color: var(--background--active); } + +.headerActionsMenu { + --n8n--dropdown-menu-width: var(--spacing--5xl); +} From 345b7aaabe6872f8e9cd77c606c78fa53225e0df Mon Sep 17 00:00:00 2001 From: Riqwan Thamir Date: Fri, 10 Jul 2026 09:59:35 +0200 Subject: [PATCH 02/50] perf(core): Reduce Instance AI initial tool context (no-changelog) (#33815) Co-authored-by: Cursor --- packages/@n8n/instance-ai/docs/tools.md | 5 ++- .../__tests__/workspace-tool-contract.test.ts | 40 +++++++++++++++++++ .../skills/n8n-docs-assistant/SKILL.md | 9 +++-- .../skills/planned-task-runtime/SKILL.md | 9 +++-- .../@n8n/instance-ai/skills/planning/SKILL.md | 11 +++-- .../agent/__tests__/instance-agent.test.ts | 12 ++++-- .../src/agent/__tests__/system-prompt.test.ts | 15 +++++-- .../instance-ai/src/agent/shared-prompts.ts | 8 ++-- .../instance-ai/src/agent/system-prompt.ts | 14 +++---- .../skills/__tests__/runtime-skills.test.ts | 9 ++++- .../instance-ai/src/tools/n8n-docs.tool.ts | 2 +- .../orchestration/__tests__/plan.tool.test.ts | 2 +- .../src/tools/orchestration/plan.tool.ts | 16 +++++--- .../@n8n/instance-ai/src/tools/tool-ids.ts | 3 -- .../__tests__/lazy-runtime-workspace.test.ts | 12 ++++-- .../src/workspace/lazy-runtime-workspace.ts | 17 +++++++- 16 files changed, 141 insertions(+), 43 deletions(-) diff --git a/packages/@n8n/instance-ai/docs/tools.md b/packages/@n8n/instance-ai/docs/tools.md index af92551ec56..ed07228e936 100644 --- a/packages/@n8n/instance-ai/docs/tools.md +++ b/packages/@n8n/instance-ai/docs/tools.md @@ -14,7 +14,8 @@ them. Some are conditional on context availability. Persist a dependency-aware task plan for detached multi-step execution. For initial plan-worthy work, the orchestrator loads the `planning` skill, performs -discovery with normal domain tools, then calls `create-tasks` with +discovery with normal domain tools, loads `create-tasks` via `load_tool`, then +calls `create-tasks` with `planningContext.source: "planning-skill"`. For `` turns, use `planningContext.source: "replan"` when multiple dependent tasks still need @@ -758,6 +759,8 @@ only the domain tools wired into that agent. | Tool Category | Orchestrator | Specialized background agents | |---------------|:---:|:---:| | Orchestration tools (`create-tasks`, etc.) | ✅ | ❌ | +| Docs search (`n8n-docs`) | ✅ (search/load) | ❌ | +| Eval tools (`evals`) | ✅ (search/load) | ❌ | | Workflow tools | ✅ | ✅ (eval-setup) | | Execution tools | ✅ | ❌ | | Credential tools | ✅ | ✅ (eval-setup — setup only) | diff --git a/packages/@n8n/instance-ai/evaluations/__tests__/workspace-tool-contract.test.ts b/packages/@n8n/instance-ai/evaluations/__tests__/workspace-tool-contract.test.ts index dcc43617696..a35e28abc47 100644 --- a/packages/@n8n/instance-ai/evaluations/__tests__/workspace-tool-contract.test.ts +++ b/packages/@n8n/instance-ai/evaluations/__tests__/workspace-tool-contract.test.ts @@ -1,5 +1,6 @@ import { createWorkspaceTools } from '@n8n/agents'; +import { INSTANCE_AI_WORKSPACE_TOOL_ALLOWLIST } from '../../src/workspace/lazy-runtime-workspace'; import { IGNORED_WORKSPACE_TOOLS, REPLAYED_WORKSPACE_TOOL_ARGS } from '../harness/langsmith-seed'; // The seed reconstructor replays @n8n/agents workspace file ops by matching tool @@ -32,4 +33,43 @@ describe('workspace tool contract (@n8n/agents drift guard)', () => { } } }); + + it('exposes only the Instance AI allowlisted tools to agents', () => { + const liveTools = createWorkspaceTools({ + filesystem: {}, + sandbox: { + executeCommand: vi.fn().mockResolvedValue({ + success: true, + exitCode: 0, + stdout: '', + stderr: '', + executionTimeMs: 0, + }), + }, + } as unknown as Parameters[0]); + const liveNames = new Set(liveTools.map((tool) => tool.name)); + + for (const name of INSTANCE_AI_WORKSPACE_TOOL_ALLOWLIST) { + expect(liveNames.has(name), name).toBe(true); + } + expect(liveNames.size).toBeGreaterThanOrEqual(INSTANCE_AI_WORKSPACE_TOOL_ALLOWLIST.size); + }); + + it('does not expose removed filesystem helpers via the allowlist', () => { + const removed = [ + 'workspace_append_file', + 'workspace_list_files', + 'workspace_file_stat', + 'workspace_mkdir', + 'workspace_rmdir', + 'workspace_delete_file', + 'workspace_copy_file', + 'workspace_move_file', + ]; + for (const name of removed) { + expect(INSTANCE_AI_WORKSPACE_TOOL_ALLOWLIST.has(name)).toBe(false); + // Historical seeds may still replay these — they remain in @n8n/agents. + expect(liveByName.has(name), `${name} should still exist for seed replay`).toBe(true); + } + }); }); diff --git a/packages/@n8n/instance-ai/skills/n8n-docs-assistant/SKILL.md b/packages/@n8n/instance-ai/skills/n8n-docs-assistant/SKILL.md index c5295bbda77..e0c2d691ecd 100644 --- a/packages/@n8n/instance-ai/skills/n8n-docs-assistant/SKILL.md +++ b/packages/@n8n/instance-ai/skills/n8n-docs-assistant/SKILL.md @@ -16,11 +16,14 @@ recommended_tools: Use this skill when the user asks how to configure, set up, troubleshoot, or understand n8n behavior and the answer should come from current n8n docs. +Before calling `n8n-docs`, load it via `load_tool` (search "n8n docs" if it is +not visible). + ## Default Procedure -1. Call `n8n-docs(action="lookup")` first for credential setup and direct n8n - docs questions. Use `search` then `read` only when you need tighter control - over candidate pages. +1. Load `n8n-docs` via `load_tool` if needed, then call `n8n-docs(action="lookup")` + first for credential setup and direct n8n docs questions. Use `search` then + `read` only when you need tighter control over candidate pages. 2. For credential setup, pass `intent: "credential-setup"` and all available context fields: `credentialType`, `credentialDisplayName`, `documentationUrl`, `oauthRedirectUrl`, and `nodeType`. diff --git a/packages/@n8n/instance-ai/skills/planned-task-runtime/SKILL.md b/packages/@n8n/instance-ai/skills/planned-task-runtime/SKILL.md index 96f1d63d2f3..00d7a528a36 100644 --- a/packages/@n8n/instance-ai/skills/planned-task-runtime/SKILL.md +++ b/packages/@n8n/instance-ai/skills/planned-task-runtime/SKILL.md @@ -19,7 +19,8 @@ recommended_tools: Load this skill when the current message contains ``, ``, ``, or immediately after calling -`create-tasks`. +`create-tasks`. Before calling `create-tasks`, load it via `load_tool` if it is +not already visible (search "create tasks" if needed). ## Silence after spawning tasks @@ -76,7 +77,8 @@ create another plan. When `` is present, a planned task failed and the graph is in `awaiting_replan`. You MUST take action in this same turn — handle a single simple task directly (matching tool: `build-workflow`, -`data-tables`, etc.), call `create-tasks` with +`data-tables`, etc.), load `create-tasks` via `load_tool` if needed and call +`create-tasks` with `planningContext.source: "replan"` for multiple dependent tasks, or explain the blocker to the user if nothing sensible remains. Do NOT reply with an acknowledgement or status update alone — the scheduler will not fire another @@ -86,7 +88,8 @@ Replan routing (do not re-plan from scratch): - One simple task remains (single data-table op, credential setup, single-workflow patch) → handle directly with the matching tool. -- Multiple dependent tasks still need scheduling → `create-tasks` with +- Multiple dependent tasks still need scheduling → load `create-tasks` via + `load_tool` if needed, then call `create-tasks` with `planningContext.source: "replan"`. - Nothing sensible remains → explain the blocker to the user. diff --git a/packages/@n8n/instance-ai/skills/planning/SKILL.md b/packages/@n8n/instance-ai/skills/planning/SKILL.md index b552bc762aa..0405ce9d42d 100644 --- a/packages/@n8n/instance-ai/skills/planning/SKILL.md +++ b/packages/@n8n/instance-ai/skills/planning/SKILL.md @@ -23,6 +23,9 @@ Use this skill to design a dependency-aware task graph in the orchestrator and submit it with `create-tasks`. Do not spawn another agent and do not use incremental plan item tools. +Before calling `create-tasks`, load it via `load_tool` (search "create tasks" if +it is not visible). + ## When NOT to use this skill Stop and use `workflow-builder` + `build-workflow` instead when the request is: @@ -69,7 +72,8 @@ with direct `data-tables` and `parse-file` calls. artifact shared across tasks. 6. Add checkpoint tasks only for exceptional semantic checks that normal workflow verification cannot cover. -7. Call `create-tasks` with `planningContext.source: "planning-skill"`, +7. Load `create-tasks` via `load_tool` if needed, then call `create-tasks` with + `planningContext.source: "planning-skill"`, a concise `summary`, optional `assumptions`, `postBuildRunRequested: true` only when the user explicitly asked to run, execute, or test a workflow after building it, and the final task graph. @@ -167,8 +171,9 @@ Do not add checkpoints for routine verification-only work. ## Revisions -If the user rejects the plan with requested changes, revise surgically and call -`create-tasks` again in the same orchestrator run with +If the user rejects the plan with requested changes, revise surgically, load +`create-tasks` via `load_tool` if needed, and call `create-tasks` again in the +same orchestrator run with `planningContext.source: "planning-skill"`. If the user denies the plan outright, stop. Do not call `create-tasks` again in diff --git a/packages/@n8n/instance-ai/src/agent/__tests__/instance-agent.test.ts b/packages/@n8n/instance-ai/src/agent/__tests__/instance-agent.test.ts index d55b1edbd5c..82d49b9330c 100644 --- a/packages/@n8n/instance-ai/src/agent/__tests__/instance-agent.test.ts +++ b/packages/@n8n/instance-ai/src/agent/__tests__/instance-agent.test.ts @@ -179,8 +179,12 @@ describe('createInstanceAgent', () => { expect(Agent).toHaveBeenCalledTimes(2); const attachedTools = getAttachedTools(); + const deferredTools = getDeferredTools(); const secondRunAttachedTools = getAttachedTools(1); - expect(attachedTools['create-tasks-run-1']).toMatchObject({ name: 'create-tasks-run-1' }); + expect(attachedTools['create-tasks-run-1']).toBeUndefined(); + expect(deferredTools['create-tasks-run-1']).toMatchObject({ name: 'create-tasks-run-1' }); + expect(attachedTools['evals-run-1']).toBeUndefined(); + expect(deferredTools['evals-run-1']).toMatchObject({ name: 'evals-run-1' }); expect(attachedTools['plan-run-1']).toBeUndefined(); expect(attachedTools['research-run-1']).toMatchObject({ name: 'research-run-1' }); expect(attachedTools['build-workflow-run-1']).toMatchObject({ @@ -555,7 +559,7 @@ describe('createInstanceAgent', () => { expect(mcpContextTools.get('github_workflows')).toMatchObject({ marker: 'github-workflows' }); }); - it('keeps evals always loaded so user-requested eval setup can route directly', async () => { + it('defers evals behind tool search so the first prompt stays smaller', async () => { const memoryConfig = {} as never; await createInstanceAgent({ @@ -576,10 +580,10 @@ describe('createInstanceAgent', () => { const attachedTools = getAttachedTools(); const deferredTools = getDeferredTools(); - expect(attachedTools['evals-evals-test']).toMatchObject({ + expect(attachedTools['evals-evals-test']).toBeUndefined(); + expect(deferredTools['evals-evals-test']).toMatchObject({ name: 'evals-evals-test', }); - expect(deferredTools['evals-evals-test']).toBeUndefined(); }); it('configures observational memory on the Memory builder when provided', async () => { diff --git a/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.test.ts b/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.test.ts index b9f846667c0..bd6004c53bb 100644 --- a/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.test.ts +++ b/packages/@n8n/instance-ai/src/agent/__tests__/system-prompt.test.ts @@ -67,9 +67,10 @@ describe('getSystemPrompt', () => { expect(prompt).toContain('## Tool Discovery'); expect(prompt).toContain('additional tools available beyond the ones listed above'); - expect(prompt).toContain('search "credential" for the credentials tool'); expect(prompt).toContain('search "file" for filesystem tools'); - expect(prompt).toContain('search "workflow" for workflow management'); + expect(prompt).toContain('search "n8n docs" for `n8n-docs`'); + expect(prompt).toContain('search "create tasks" for `create-tasks`'); + expect(prompt).toContain('search "eval" for `evals`'); expect(prompt).not.toContain('connected service or MCP integration'); expect(prompt).not.toContain('connected MCP integrations'); }); @@ -96,7 +97,7 @@ describe('getSystemPrompt', () => { expect(prompt).toContain( 'search "notion page" or "linear issue" for the corresponding MCP tool', ); - expect(prompt).toContain('search "credential" for the credentials tool'); + expect(prompt).toContain('search "n8n docs" for `n8n-docs`'); }); it('omits Tool Discovery guidance when deferred tool search is disabled even if MCP tools exist', () => { @@ -194,6 +195,7 @@ describe('getSystemPrompt', () => { expect(prompt).toContain('`build-workflow`'); expect(prompt).toContain('**Multi-workflow or coordinated architecture**'); expect(prompt).toContain('`planning`'); + expect(prompt).toContain('load `create-tasks` via `load_tool`'); expect(prompt).toContain('planningContext.source: "planning-skill"'); expect(prompt).toContain('multiple durable artifacts'); expect(prompt).toContain('shared data-table schema/migration'); @@ -218,6 +220,10 @@ describe('getSystemPrompt', () => { expect(prompt).toContain( 'Never call `data-tables` or `parse-file` without loading `data-table-manager` first', ); + expect(prompt).toContain( + 'never call `create-tasks` without loading it via `load_tool` first', + ); + expect(prompt).toContain('never call `n8n-docs` without loading it via `load_tool` first'); expect(prompt).toContain('Do not call `create-tasks`'); }); @@ -272,6 +278,7 @@ describe('getSystemPrompt', () => { expect(prompt).toContain('**n8n docs/product guidance**'); expect(prompt).toContain('credential setup'); expect(prompt).toContain('`n8n-docs-assistant`'); + expect(prompt).toContain('load `n8n-docs` via `load_tool`'); expect(prompt).toContain('`n8n-docs`'); }); @@ -314,6 +321,8 @@ describe('getSystemPrompt', () => { expect(prompt).toContain('knowledge-base/reference/index.json'); expect(prompt).not.toContain('knowledge-base/templates/index.txt'); expect(prompt).toContain('workspace_execute_command'); + expect(prompt).not.toContain('workspace_list_files'); + expect(prompt).toContain('mkdir -p'); expect(prompt).toContain('Consult the knowledge base before planning or building'); expect(prompt).not.toContain('knowledge-base/best-practices/*.md'); }); diff --git a/packages/@n8n/instance-ai/src/agent/shared-prompts.ts b/packages/@n8n/instance-ai/src/agent/shared-prompts.ts index 25aa369f62d..6447e9cc6d1 100644 --- a/packages/@n8n/instance-ai/src/agent/shared-prompts.ts +++ b/packages/@n8n/instance-ai/src/agent/shared-prompts.ts @@ -28,18 +28,20 @@ function substituteWorkspaceRoot(text: string, workspaceRoot?: string): string { export function getSandboxWorkspaceSection(workspaceRoot?: string): string { const pathHint = workspaceRoot - ? `\nWorkspace root: \`${workspaceRoot}\`. Paths below are under this root — pass them to \`workspace_read_file\`, \`workspace_list_files\`, and \`workspace_execute_command\` as shown (relative paths like \`knowledge-base/...\` also work).\n` + ? `\nWorkspace root: \`${workspaceRoot}\`. Paths below are under this root — pass them to \`workspace_read_file\` and \`workspace_execute_command\` as shown (relative paths like \`knowledge-base/...\` also work).\n` : ''; const section = `## Sandbox workspace ${pathHint} -A thread-scoped sandbox workspace is available via \`workspace_read_file\`, \`workspace_list_files\`, and \`workspace_execute_command\` (use \`grep\` or \`rg\` to search). The workspace is created on first use and includes baked-in reference material: +A thread-scoped sandbox workspace is available via \`workspace_read_file\`, \`workspace_write_file\`, \`workspace_str_replace_file\`, \`workspace_batch_str_replace_file\`, and \`workspace_execute_command\`. The workspace is created on first use and includes baked-in reference material: - \`/knowledge-base/index.json\` — catalog of workflow technique guides (\`/knowledge-base/best-practices/index.json\`; read the linked \`.md\` files) and orchestration reference docs (\`/knowledge-base/reference/index.json\`, e.g. trigger \`inputData\` shapes for verification) -- \`/knowledge-base/templates/\` — curated SDK workflow examples: \`grep\` or \`rg\` this folder to find matches, then read only the relevant \`.ts\` files — never load \`templates/index.json\` wholesale +- \`/knowledge-base/templates/\` — curated SDK workflow examples: use \`workspace_execute_command\` with \`rg\` or \`find\` to locate matches, then read only the relevant \`.ts\` files — never load \`templates/index.json\` wholesale - \`/node-types/index.txt\` — searchable catalog of available n8n nodes - \`/workflows/*.json\` — existing workflows on this instance (when synced) +For directory listing, file metadata, mkdir, copy, move, or delete, use \`workspace_execute_command\` with standard shell commands (\`ls\`, \`find\`, \`stat\`, \`mkdir -p\`, \`cp\`, \`mv\`, \`rm\`). Do not expect dedicated tools for these operations. + **Consult the knowledge base before planning or building.** Read the relevant \`.md\` guides and templates for each technique the request involves. Skip only for trivial mechanical edits you have already reviewed in this thread.`; return substituteWorkspaceRoot(section, workspaceRoot); diff --git a/packages/@n8n/instance-ai/src/agent/system-prompt.ts b/packages/@n8n/instance-ai/src/agent/system-prompt.ts index 8b9046dcf54..8443e214a46 100644 --- a/packages/@n8n/instance-ai/src/agent/system-prompt.ts +++ b/packages/@n8n/instance-ai/src/agent/system-prompt.ts @@ -132,16 +132,16 @@ ${workspaceRoot ? `\n${getSandboxWorkspaceSection(workspaceRoot)}\n` : ''} You have access to workflow, execution, and credential tools plus runtime skills (see the skill catalog), and may have access to MCP tools for extended capabilities. ${getProjectScopeSection(projectId)} -Match the user's request against skill descriptions in the catalog. Call \`load_skill\` before acting on a matched skill's guidance. Never call \`data-tables\` or \`parse-file\` without loading \`data-table-manager\` first, and never call \`build-workflow\` without loading \`workflow-builder\` first. A single turn may need more than one skill when routing requires it (e.g. \`data-table-manager\` then \`workflow-builder\`). +Match the user's request against skill descriptions in the catalog. Call \`load_skill\` before acting on a matched skill's guidance. Never call \`data-tables\` or \`parse-file\` without loading \`data-table-manager\` first, never call \`build-workflow\` without loading \`workflow-builder\` first, never call \`create-tasks\` without loading it via \`load_tool\` first (search "create tasks" if it is not visible), and never call \`n8n-docs\` without loading it via \`load_tool\` first (search "n8n docs" if it is not visible). A single turn may need more than one skill when routing requires it (e.g. \`data-table-manager\` then \`workflow-builder\`). ${INTENT_HINT} - **Single workflow build or edit** (new workflow, add/remove/rewire nodes, expression/credential/schedule/Code fixes, including workflows that create or write to Data Tables) → \`data-table-manager\` when tables are involved, then \`workflow-builder\` → \`build-workflow\` (pass the source as \`sourceCode\`). When the needed node types are already obvious from the request, batch \`nodes(action="type-definition")\` — object form with resource/operation or mode discriminators — together with the \`load_skill\` call in your first action turn (each extra sequential turn resends the whole context); when unsure which nodes to use, load the skill first and follow its research process. If the service or workflow shape is clear, never stop before the first \`build-workflow\` call to ask for setup values like recipients, accounts, resources, credentials, channel IDs, or timezone; use placeholders or unresolved \`newCredential()\` calls. After every successful direct \`build-workflow\` result, if the tool output contains \`postBuildFlow.required: true\`, follow the inlined \`postBuildFlow.instructions\` (do not load \`post-build-flow\` separately) before verification, setup, error-workflow follow-up, publishing, testing, or any final user-visible summary. Do not create a plan just for verification. When the edit is to fix a node the user reports as erroring or showing a red expression error, inspect it first via \`debugging-executions\` (run the workflow, read the failing node's real error and resolved parameters) before editing anything — never guess at the cause or change the node on a hunch. -- **Multi-workflow or coordinated architecture** (dependencies between workflows, shared data-table schema/migration, multiple durable artifacts, broad research, ambiguous business process, user asks to review a plan) → \`data-table-manager\` first when shared tables are involved → \`planning\` → \`create-tasks\` with \`planningContext.source: "planning-skill"\`. +- **Multi-workflow or coordinated architecture** (dependencies between workflows, shared data-table schema/migration, multiple durable artifacts, broad research, ambiguous business process, user asks to review a plan) → \`data-table-manager\` first when shared tables are involved → \`planning\` → load \`create-tasks\` via \`load_tool\` → call \`create-tasks\` with \`planningContext.source: "planning-skill"\`. - **Non-build workflow ops** (rename, toggle active, duplicate, move, describe, list executions, publish, delete) → direct \`workflows\` / \`executions\` tools. Do not run the builder. - **Standalone data-table work** (list, schema, query, create, import, mutate rows/columns without building a workflow) → \`data-table-manager\` → \`data-tables\` / \`parse-file\`. Natural requests like "what data tables do I have?", "show/list my tables", and "what columns are in this table?" count as standalone data-table work. Do not call \`create-tasks\`. - **Execution debugging** (failed runs, wrong/empty node output, a node reported as erroring or showing a red expression error) → \`debugging-executions\`. Inspect the real failure via \`executions\` before editing — never edit a reported-erroring node on a hunch. -- **n8n docs/product guidance** (credential setup, how to configure n8n features, hosting/API/node docs questions) → \`n8n-docs-assistant\` → \`n8n-docs\`. +- **n8n docs/product guidance** (credential setup, how to configure n8n features, hosting/API/node docs questions) → \`n8n-docs-assistant\` → load \`n8n-docs\` via \`load_tool\` → call \`n8n-docs\`. - **Browser credential setup** when \`credentials(action="setup")\` returns \`needsBrowserSetup=true\` → \`credential-setup-with-computer-use\`, then use Computer Use \`browser_*\` tools directly. Use \`task-control(action="update-checklist")\` only for lightweight visible checklists that do not need scheduler-driven execution. @@ -161,7 +161,7 @@ After calling \`create-tasks\`, load \`planned-task-runtime\` guidance for silen ## Tool conventions - **Include entity names** — when a tool accepts an optional name parameter (e.g. \`workflowName\`, \`folderName\`, \`credentialName\`), always pass it. The name is shown to the user in confirmation dialogs. -- **Web research** — use \`research\` directly for most questions. Load \`planning\` and \`create-tasks\` only for broad detached synthesis across many sources. +- **Web research** — use \`research\` directly for most questions. Load \`planning\` and load \`create-tasks\` via \`load_tool\` only for broad detached synthesis across many sources. ${SECRET_ASK_GUARDRAIL} @@ -169,7 +169,7 @@ ${ toolSearchEnabled ? `## Tool Discovery -You have additional tools available beyond the ones listed above — including credential management, workflow operations, node browsing, data tables, filesystem access${mcpToolSearchEnabled ? ', and connected MCP integrations' : ''}. +You have additional tools available beyond the ones listed above — including filesystem access, task planning, n8n docs search, evals, and orchestration helpers${mcpToolSearchEnabled ? ', plus connected MCP integrations' : ''}. ${ mcpToolSearchEnabled @@ -177,9 +177,9 @@ ${ ` : '' -}When the visible tools do not cover the user's request, use \`search_tools\` with keyword queries to find relevant tools, then \`load_tool\` to activate them. Loaded tools persist for the rest of the conversation. +}When the visible tools do not cover the user's request, use \`search_tools\` with keyword queries to find relevant tools, then \`load_tool\` to activate them. Loaded tools persist for the rest of the conversation. When a loaded skill names a tool you do not see, search for that tool name and load it before proceeding. -Examples: ${mcpToolSearchEnabled ? 'search "notion page" or "linear issue" for the corresponding MCP tool, ' : ''}search "credential" for the credentials tool, search "file" for filesystem tools, search "workflow" for workflow management. +Examples: ${mcpToolSearchEnabled ? 'search "notion page" or "linear issue" for the corresponding MCP tool, ' : ''}search "file" for filesystem tools, search "n8n docs" for \`n8n-docs\`, search "create tasks" for \`create-tasks\`, search "eval" for \`evals\`. ` : '' diff --git a/packages/@n8n/instance-ai/src/skills/__tests__/runtime-skills.test.ts b/packages/@n8n/instance-ai/src/skills/__tests__/runtime-skills.test.ts index f57fe68cca5..21a7e687554 100644 --- a/packages/@n8n/instance-ai/src/skills/__tests__/runtime-skills.test.ts +++ b/packages/@n8n/instance-ai/src/skills/__tests__/runtime-skills.test.ts @@ -139,6 +139,7 @@ describe('Instance AI runtime skills', () => { expect(skill?.linkedFiles.references).toEqual([]); const loaded = await source.loadSkill('n8n-docs-assistant'); + expect(loaded?.instructions).toContain('Before calling `n8n-docs`, load it via `load_tool`'); expect(loaded?.instructions).toContain('n8n-docs(action="lookup")'); expect(loaded?.instructions).toContain('intent: "credential-setup"'); expect(loaded?.instructions).toContain('oauthRedirectUrl'); @@ -221,6 +222,9 @@ describe('Instance AI runtime skills', () => { const loaded = await source.loadSkill('planning'); expect(loaded?.instructions).toContain('## When NOT to use this skill'); + expect(loaded?.instructions).toContain( + 'Before calling `create-tasks`, load it via `load_tool`', + ); expect(loaded?.instructions).toContain('Do not call `create-tasks` just to get approval'); expect(loaded?.instructions).toContain('planningContext.source: "planning-skill"'); expect(loaded?.instructions).toContain('Do not spawn another agent'); @@ -315,7 +319,10 @@ describe('Instance AI runtime skills', () => { expect(skill?.description).toContain('planned-task-follow-up'); const loaded = await source.loadSkill('planned-task-runtime'); - expect(loaded?.instructions).toContain(''); + expect(loaded?.instructions).toContain( + 'Before calling `create-tasks`, load it via `load_tool`', + ); + expect(loaded?.instructions).toContain('load `create-tasks` via `load_tool` if needed'); expect(loaded?.instructions).toContain('You MUST take action in this same turn'); expect(loaded?.instructions).toContain('awaiting_replan'); expect(loaded?.instructions).toMatch(/Do NOT reply with an\s+acknowledgement/); diff --git a/packages/@n8n/instance-ai/src/tools/n8n-docs.tool.ts b/packages/@n8n/instance-ai/src/tools/n8n-docs.tool.ts index 83a7df5d0e1..520b1f676ea 100644 --- a/packages/@n8n/instance-ai/src/tools/n8n-docs.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/n8n-docs.tool.ts @@ -187,7 +187,7 @@ async function handleRead(context: Pick, input: N8n export function createN8nDocsTool(context: Pick) { return new Tool(N8N_DOCS_TOOL_ID) .description( - `Search and read current n8n documentation from docs.n8n.io. Use for n8n product, setup, credential, node, hosting, API, and troubleshooting questions. ${SOURCE_ATTRIBUTION_INSTRUCTION}`, + `Search and read current n8n documentation from docs.n8n.io. Load via \`load_tool\` before calling (search "n8n docs" if not visible). Use for n8n product, setup, credential, node, hosting, API, and troubleshooting questions. ${SOURCE_ATTRIBUTION_INSTRUCTION}`, ) .input(n8nDocsToolInputSchema) .handler(async (input) => { diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/plan.tool.test.ts b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/plan.tool.test.ts index cd40bfe36b4..3d6a044c37e 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/plan.tool.test.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/__tests__/plan.tool.test.ts @@ -260,7 +260,7 @@ describe('createPlanTool — planning context guard', () => { expect(out.taskCount).toBe(0); expect(out.result).toContain( - 'must call `create-tasks` with `planningContext.source: "replan"`', + 'must load `create-tasks` via `load_tool` if needed, then call `create-tasks`', ); expect(context.plannedTaskService!.createPlan).not.toHaveBeenCalled(); }); diff --git a/packages/@n8n/instance-ai/src/tools/orchestration/plan.tool.ts b/packages/@n8n/instance-ai/src/tools/orchestration/plan.tool.ts index 0408ad1fb43..d9d0644cf20 100644 --- a/packages/@n8n/instance-ai/src/tools/orchestration/plan.tool.ts +++ b/packages/@n8n/instance-ai/src/tools/orchestration/plan.tool.ts @@ -108,8 +108,9 @@ function validatePlanningContext( }); return ( 'Error: `create-tasks` requires `planningContext`. For initial plan-worthy work, load the ' + - '`planning` skill first, perform discovery with normal tools, then call `create-tasks` with ' + - '`planningContext.source: "planning-skill"`. For planned-task replan follow-ups, use ' + + '`planning` skill first, perform discovery with normal tools, load `create-tasks` via ' + + '`load_tool`, then call `create-tasks` with `planningContext.source: "planning-skill"`. ' + + 'For planned-task replan follow-ups, load `create-tasks` if needed, then use ' + '`planningContext.source: "replan"`.' ); } @@ -121,8 +122,9 @@ function validatePlanningContext( source: planningContext.source, }); return ( - 'Error: `` turns must call `create-tasks` with ' + - '`planningContext.source: "replan"` when scheduling multiple dependent tasks.' + 'Error: `` turns must load `create-tasks` via ' + + '`load_tool` if needed, then call `create-tasks` with `planningContext.source: "replan"` ' + + 'when scheduling multiple dependent tasks.' ); } return undefined; @@ -136,7 +138,8 @@ function validatePlanningContext( return ( 'Error: `planningContext.source: "replan"` is only valid in planned-task replan follow-up turns. ' + 'For initial plan-worthy work, load the `planning` skill, perform discovery with normal tools, ' + - 'then call `create-tasks` with `planningContext.source: "planning-skill"`.' + 'load `create-tasks` via `load_tool`, then call `create-tasks` with ' + + '`planningContext.source: "planning-skill"`.' ); } @@ -147,6 +150,7 @@ export function createPlanTool(context: OrchestrationContext) { return new Tool('create-tasks') .description( 'Submit a dependency-aware task graph for detached multi-step execution. ' + + 'Load via `load_tool` before calling (search "create tasks" if not visible). ' + 'Use after loading the `planning` skill for initial plan-worthy work, or during ' + '`` when multiple dependent tasks still need scheduling. ' + 'Requires `planningContext.source` to be `planning-skill` or `replan` as appropriate. ' + @@ -324,7 +328,7 @@ export function createPlanTool(context: OrchestrationContext) { // graph cannot dispatch, and the next `create-tasks` call overwrites // it with the revised graph. return { - result: `User requested changes: ${resumeData.userInput ?? 'No feedback provided'}. Revise the tasks and call create-tasks again.`, + result: `User requested changes: ${resumeData.userInput ?? 'No feedback provided'}. Revise the tasks, load create-tasks via load_tool if needed, and call create-tasks again.`, taskCount: 0, }; }) diff --git a/packages/@n8n/instance-ai/src/tools/tool-ids.ts b/packages/@n8n/instance-ai/src/tools/tool-ids.ts index 0e23b194ddb..c366ea24b22 100644 --- a/packages/@n8n/instance-ai/src/tools/tool-ids.ts +++ b/packages/@n8n/instance-ai/src/tools/tool-ids.ts @@ -78,7 +78,6 @@ export const N8N_DOCS_TOOL_ID = DOMAIN_TOOL_IDS.N8N_DOCS; export const ORCHESTRATION_TOOL_NAMES = new Set(Object.values(ORCHESTRATION_TOOL_IDS)); export const ALWAYS_LOADED_TOOL_NAMES = new Set([ - ORCHESTRATION_TOOL_IDS.CREATE_TASKS, DOMAIN_TOOL_IDS.ASK_USER, DOMAIN_TOOL_IDS.CREDENTIALS, DOMAIN_TOOL_IDS.WORKFLOWS, @@ -87,10 +86,8 @@ export const ALWAYS_LOADED_TOOL_NAMES = new Set([ DOMAIN_TOOL_IDS.PARSE_FILE, DOMAIN_TOOL_IDS.BUILD_WORKFLOW, DOMAIN_TOOL_IDS.NODES, - DOMAIN_TOOL_IDS.N8N_DOCS, ORCHESTRATION_TOOL_IDS.VERIFY_BUILT_WORKFLOW, DOMAIN_TOOL_IDS.RESEARCH, - DOMAIN_TOOL_IDS.EVALS, 'web-search', 'fetch-url', ]); diff --git a/packages/@n8n/instance-ai/src/workspace/__tests__/lazy-runtime-workspace.test.ts b/packages/@n8n/instance-ai/src/workspace/__tests__/lazy-runtime-workspace.test.ts index aa5e3e3e7e0..ae394aad926 100644 --- a/packages/@n8n/instance-ai/src/workspace/__tests__/lazy-runtime-workspace.test.ts +++ b/packages/@n8n/instance-ai/src/workspace/__tests__/lazy-runtime-workspace.test.ts @@ -5,7 +5,10 @@ import { type WorkspaceSandbox, } from '@n8n/agents'; -import { createLazyRuntimeWorkspace } from '../lazy-runtime-workspace'; +import { + INSTANCE_AI_WORKSPACE_TOOL_ALLOWLIST, + createLazyRuntimeWorkspace, +} from '../lazy-runtime-workspace'; function createMockWorkspace() { const executeCommand = vi.fn< @@ -88,8 +91,11 @@ describe('createLazyRuntimeWorkspace', () => { lazyWorkspace.getInstructions(); expect(ensureWorkspace).not.toHaveBeenCalled(); - expect(tools.some((tool) => tool.name === 'workspace_read_file')).toBe(true); - expect(tools.some((tool) => tool.name === 'workspace_execute_command')).toBe(true); + expect(tools.map((tool) => tool.name).sort()).toEqual( + [...INSTANCE_AI_WORKSPACE_TOOL_ALLOWLIST].sort(), + ); + expect(tools.some((tool) => tool.name === 'workspace_list_files')).toBe(false); + expect(tools.some((tool) => tool.name === 'workspace_append_file')).toBe(false); const readFile = tools.find((tool) => tool.name === 'workspace_read_file'); await readFile?.handler?.({ path: '/workspace/report.md' }, {}); diff --git a/packages/@n8n/instance-ai/src/workspace/lazy-runtime-workspace.ts b/packages/@n8n/instance-ai/src/workspace/lazy-runtime-workspace.ts index e16367ea845..8f320b1fa18 100644 --- a/packages/@n8n/instance-ai/src/workspace/lazy-runtime-workspace.ts +++ b/packages/@n8n/instance-ai/src/workspace/lazy-runtime-workspace.ts @@ -19,6 +19,15 @@ import { export type RuntimeWorkspaceResolver = () => Promise; +/** Workspace tools exposed to Instance AI agents — read/write/replace/execute only. */ +export const INSTANCE_AI_WORKSPACE_TOOL_ALLOWLIST = new Set([ + 'workspace_read_file', + 'workspace_write_file', + 'workspace_str_replace_file', + 'workspace_batch_str_replace_file', + 'workspace_execute_command', +]); + export interface LazyRuntimeWorkspaceOptions { ensureWorkspace: RuntimeWorkspaceResolver; id?: string; @@ -47,12 +56,18 @@ export function createLazyRuntimeWorkspace({ }: LazyRuntimeWorkspaceOptions): Workspace { const resolver = new LazyRuntimeWorkspaceResolver(ensureWorkspace); - return new Workspace({ + const workspace = new Workspace({ id, name, filesystem: new LazyRuntimeFilesystem(resolver, filesystemInstructions), sandbox: new LazyRuntimeSandbox(resolver, sandboxInstructions), }); + + const baseGetTools = workspace.getTools.bind(workspace); + workspace.getTools = () => + baseGetTools().filter((tool) => INSTANCE_AI_WORKSPACE_TOOL_ALLOWLIST.has(tool.name)); + + return workspace; } class LazyRuntimeWorkspaceResolver { From c33d6494f7a3173c4eba571c53b5eddb61b24a20 Mon Sep 17 00:00:00 2001 From: RomanDavydchuk Date: Fri, 10 Jul 2026 11:04:32 +0300 Subject: [PATCH 03/50] feat(core): Improve AI Assistant and MCP registry integration telemetry (no-changelog) (#33686) --- .../instanceAi/components/ConnectionsCard.vue | 3 +- .../__tests__/ConnectionsCard.test.ts | 107 +++++-- .../InstanceAiToolsConnectionModalWrapper.vue | 35 +++ ...tanceAiToolsConnectionModalWrapper.test.ts | 262 ++++++++++++++++++ .../ai/instanceAi/instanceAiMcp.telemetry.ts | 31 ++- .../toolsConnection/ToolCredentialPicker.vue | 15 +- .../shared/toolsConnection/ToolDetailView.vue | 6 + .../shared/toolsConnection/ToolRow.vue | 9 + .../toolsConnection/ToolSettingsView.vue | 6 + .../toolsConnection/ToolsConnectionModal.vue | 12 + .../__tests__/ToolCredentialPicker.test.ts | 33 +++ .../toolsConnection/__tests__/ToolRow.test.ts | 13 + 12 files changed, 509 insertions(+), 23 deletions(-) create mode 100644 packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/__tests__/InstanceAiToolsConnectionModalWrapper.test.ts diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionsCard.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionsCard.vue index 080ab8c3e61..b405a7c2d8f 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionsCard.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/ConnectionsCard.vue @@ -125,7 +125,7 @@ async function openSingletonModal(type: SingletonConnectionType) { } function openToolsConnectionModal() { - mcpTelemetry.trackModalOpened(); + mcpTelemetry.trackToolsListOpened(); uiStore.openModal(INSTANCE_AI_TOOLS_CONNECTION_MODAL_KEY); } @@ -152,7 +152,6 @@ function openMcpSettings(connectionId: string) { if (connection) { mcpTelemetry.trackSettingsOpened(connection.serverSlug); } - mcpTelemetry.trackModalOpened(); uiStore.openModalWithData({ name: INSTANCE_AI_TOOLS_CONNECTION_MODAL_KEY, data: { connectionId }, diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/ConnectionsCard.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/ConnectionsCard.test.ts index 293b576d572..bb1934437d4 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/ConnectionsCard.test.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/__tests__/ConnectionsCard.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { fireEvent } from '@testing-library/vue'; import { ref } from 'vue'; import { createTestingPinia } from '@pinia/testing'; import { setActivePinia } from 'pinia'; @@ -37,28 +38,30 @@ vi.mock('../../instanceAiSettings.store', () => ({ useInstanceAiSettingsStore: () => settingsStoreMock(), })); +const mcpStoreMock = vi.fn(); vi.mock('../../instanceAiMcp.store', () => ({ - useInstanceAiMcpStore: () => ({ - connections: [], - fetchConnections: vi.fn(), - disconnect: vi.fn(), - }), + useInstanceAiMcpStore: () => mcpStoreMock(), })); -vi.mock('../../instanceAiMcp.telemetry', () => ({ - useInstanceAiMcpTelemetry: () => ({ +const { telemetryMock, uiStoreMock } = vi.hoisted(() => ({ + telemetryMock: { trackAddMenuMcpSelected: vi.fn(), - trackModalOpened: vi.fn(), + trackToolsListOpened: vi.fn(), trackSettingsOpened: vi.fn(), - }), -})); - -vi.mock('@/app/stores/ui.store', () => ({ - useUIStore: () => ({ + }, + uiStoreMock: { openModal: vi.fn(), openModalWithData: vi.fn(), appliedTheme: 'light', - }), + }, +})); + +vi.mock('../../instanceAiMcp.telemetry', () => ({ + useInstanceAiMcpTelemetry: () => telemetryMock, +})); + +vi.mock('@/app/stores/ui.store', () => ({ + useUIStore: () => uiStoreMock, })); const COMPUTER_USE_CONNECTION = { @@ -93,12 +96,32 @@ function makeSettingsStore(overrides: Record = {}) { }; } +function makeMcpStore(overrides: Record = {}) { + return { + connections: [], + fetchConnections: vi.fn(), + disconnect: vi.fn(), + ...overrides, + }; +} + const renderComponent = createComponentRenderer(ConnectionsCard, { global: { stubs: { ConnectionRow: { - props: ['name'], - template: '
{{ name }}
', + props: ['name', 'actions'], + template: ` +
+ {{ name }} + +
+ `, }, }, }, @@ -112,6 +135,7 @@ describe('ConnectionsCard', () => { computerUseExperimentMock.mockReturnValue({ isFeatureEnabled: ref(true) }); browserUseExperimentMock.mockReturnValue({ isFeatureEnabled: ref(true) }); settingsStoreMock.mockReturnValue(makeSettingsStore()); + mcpStoreMock.mockReturnValue(makeMcpStore()); }); it('renders the browser use row when the experiment is enabled', () => { @@ -171,6 +195,57 @@ describe('ConnectionsCard', () => { expect(getByTestId('instance-ai-connections-empty-cta')).toBeVisible(); }); + it('tracks opening the tools list from the add button', async () => { + mcpExperimentMock.mockReturnValue({ isFeatureEnabled: ref(true) }); + settingsStoreMock.mockReturnValue(makeSettingsStore({ settings: { mcpAccessEnabled: true } })); + + const { getByTestId } = renderComponent(); + await fireEvent.click(getByTestId('instance-ai-connections-add')); + + expect(telemetryMock.trackToolsListOpened).toHaveBeenCalledTimes(1); + expect(uiStoreMock.openModal).toHaveBeenCalledTimes(1); + }); + + it('tracks opening the tools list from the empty-state CTA', async () => { + mcpExperimentMock.mockReturnValue({ isFeatureEnabled: ref(true) }); + settingsStoreMock.mockReturnValue( + makeSettingsStore({ connections: [], settings: { mcpAccessEnabled: true } }), + ); + + const { getByTestId } = renderComponent(); + await fireEvent.click(getByTestId('instance-ai-connections-empty-cta')); + + expect(telemetryMock.trackToolsListOpened).toHaveBeenCalledTimes(1); + expect(uiStoreMock.openModal).toHaveBeenCalledTimes(1); + }); + + it('tracks opening connected MCP server settings separately from the tools list', async () => { + mcpExperimentMock.mockReturnValue({ isFeatureEnabled: ref(true) }); + settingsStoreMock.mockReturnValue( + makeSettingsStore({ connections: [], settings: { mcpAccessEnabled: true } }), + ); + mcpStoreMock.mockReturnValue( + makeMcpStore({ + connections: [ + { + id: 'conn-1', + serverSlug: 'linear', + serverTitle: 'Linear', + serverIcons: [], + credentialName: 'Linear OAuth2', + }, + ], + }), + ); + + const { getByTestId } = renderComponent(); + await fireEvent.click(getByTestId('connection-row-settings-Linear')); + + expect(telemetryMock.trackSettingsOpened).toHaveBeenCalledWith('linear'); + expect(telemetryMock.trackToolsListOpened).not.toHaveBeenCalled(); + expect(uiStoreMock.openModalWithData).toHaveBeenCalledTimes(1); + }); + describe('card visibility', () => { it('hides the entire card when no channel is enabled', () => { browserUseExperimentMock.mockReturnValue({ isFeatureEnabled: ref(false) }); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/InstanceAiToolsConnectionModalWrapper.vue b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/InstanceAiToolsConnectionModalWrapper.vue index fde1d62a159..7098a11002f 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/InstanceAiToolsConnectionModalWrapper.vue +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/InstanceAiToolsConnectionModalWrapper.vue @@ -23,6 +23,7 @@ import { type ToolConnectionSettings, } from '@/features/shared/toolsConnection/types'; import { useInstanceAiMcpStore } from '../../instanceAiMcp.store'; +import { useInstanceAiMcpTelemetry } from '../../instanceAiMcp.telemetry'; import { useInstanceAiSettingsStore } from '../../instanceAiSettings.store'; import type { InstanceAiMcpConnectionResponse, @@ -56,6 +57,7 @@ const props = defineProps<{ const uiStore = useUIStore(); const credentialsStore = useCredentialsStore(); const mcpStore = useInstanceAiMcpStore(); +const mcpTelemetry = useInstanceAiMcpTelemetry(); const settingsStore = useInstanceAiSettingsStore(); const toast = useToast(); const { canOAuthCredentialQuickConnect, createAndAuthorize } = useCredentialOAuth(); @@ -407,6 +409,34 @@ function findServerForItem(item: McpServerConnectionItem): McpRegistryServerResp return mcpStore.catalog?.find((s) => s.slug === slug); } +function trackMcpCredentialInteraction( + item: ToolConnectionItem, + track: (serverSlug: string) => void, +): void { + if (item.kind !== 'mcp-server') return; + const server = findServerForItem(item); + if (!server) return; + track(server.slug); +} + +function handleFirstCredentialConnect(item: ToolConnectionItem): void { + trackMcpCredentialInteraction(item, (serverSlug) => { + mcpTelemetry.trackFirstCredentialConnectionStart(serverSlug); + }); +} + +function handleCredentialDropdownOpen(item: ToolConnectionItem): void { + trackMcpCredentialInteraction(item, (serverSlug) => { + mcpTelemetry.trackCredentialDropdownOpened(serverSlug); + }); +} + +function handleNewCredentialConnect(item: ToolConnectionItem): void { + trackMcpCredentialInteraction(item, (serverSlug) => { + mcpTelemetry.trackNewCredentialConnectionStart(serverSlug); + }); +} + async function handleSelectCredential( item: ToolConnectionItem, _authType: string, @@ -415,6 +445,7 @@ async function handleSelectCredential( if (item.kind !== 'mcp-server') return; const server = findServerForItem(item); if (!server) return; + mcpTelemetry.trackExistingCredentialSelected(server.slug); const ok = await connectOrSwapCredential(server.slug, credentialId); if (ok) showSettingsForServer(server.slug); } @@ -427,6 +458,7 @@ async function handleSave(item: ToolConnectionItem, settings?: ToolConnectionSet excludedTools: settings.excludedTools, }); if (!updated) return; + mcpTelemetry.trackToolFilterSettingsUpdated(updated.serverSlug, settings.inclusionMode); toast.showMessage({ type: 'success', title: i18n.baseText('instanceAi.mcp.settings.saved'), @@ -464,6 +496,9 @@ async function handleConnect(item: ToolConnectionItem) { :hide-back-button="shouldHideBackButton" @update:detail-item="(item) => (activeItemId = item?.id ?? null)" @select-credential="handleSelectCredential" + @credential-dropdown-open="handleCredentialDropdownOpen" + @first-credential-connect="handleFirstCredentialConnect" + @new-credential-connect="handleNewCredentialConnect" @connect="handleConnect" @save="handleSave" @disconnect="handleDisconnect" diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/__tests__/InstanceAiToolsConnectionModalWrapper.test.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/__tests__/InstanceAiToolsConnectionModalWrapper.test.ts new file mode 100644 index 00000000000..566dcd5bb51 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/components/modals/__tests__/InstanceAiToolsConnectionModalWrapper.test.ts @@ -0,0 +1,262 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { flushPromises } from '@vue/test-utils'; +import { defineComponent } from 'vue'; +import { createComponentRenderer } from '@/__tests__/render'; +import InstanceAiToolsConnectionModalWrapper from '../InstanceAiToolsConnectionModalWrapper.vue'; +import type { + McpServerConnectionItem, + ToolConnectionSettings, +} from '@/features/shared/toolsConnection/types'; + +vi.mock('@n8n/i18n', async (importOriginal) => ({ + ...(await importOriginal()), + i18n: { baseText: (key: string) => key }, +})); + +vi.mock('@/experiments/instanceAiMcpConnections', () => ({ + useInstanceAiMcpConnectionsExperiment: () => ({ isFeatureEnabled: { value: true } }), +})); + +vi.mock('@/experiments/instanceAiComputerUse', () => ({ + useInstanceAiComputerUseExperiment: () => ({ isFeatureEnabled: { value: false } }), +})); + +vi.mock('@/experiments/instanceAiBrowserUse', () => ({ + useInstanceAiBrowserUseExperiment: () => ({ isFeatureEnabled: { value: false } }), +})); + +const { mockConnect, mockUpdateConnection, mcpStoreMock } = vi.hoisted(() => { + const mockConnect = vi.fn(); + const mockUpdateConnection = vi.fn(); + return { + mockConnect, + mockUpdateConnection, + mcpStoreMock: { + connections: [] as Array<{ id: string; serverSlug: string; credentialId?: string }>, + catalog: [] as Array<{ + slug: string; + title: string; + tagline: string; + description: string; + credentialType: string; + tools: never[]; + icons: never[]; + isOfficial: boolean; + version: string; + websiteUrl: string; + }>, + connectionsByServerSlug: new Map(), + connectionToolsById: new Map(), + fetchCatalogLazy: vi.fn(), + fetchConnections: vi.fn(), + fetchConnectionToolsLazy: vi.fn(), + connect: mockConnect, + updateConnection: mockUpdateConnection, + disconnect: vi.fn(), + }, + }; +}); +vi.mock('../../../instanceAiMcp.store', () => ({ + useInstanceAiMcpStore: () => mcpStoreMock, +})); + +vi.mock('../../../instanceAiSettings.store', () => ({ + useInstanceAiSettingsStore: () => ({ + settings: { mcpAccessEnabled: true }, + isLocalGatewayDisabledByAdmin: false, + isBrowserUseEnabledByAdmin: false, + isGatewayConnected: false, + isBrowserUseConnected: false, + }), +})); + +const { telemetryMock, uiStoreMock } = vi.hoisted(() => ({ + telemetryMock: { + trackToolFilterSettingsUpdated: vi.fn(), + trackFirstCredentialConnectionStart: vi.fn(), + trackCredentialDropdownOpened: vi.fn(), + trackExistingCredentialSelected: vi.fn(), + trackNewCredentialConnectionStart: vi.fn(), + }, + uiStoreMock: { + modalsById: { + instanceAiToolsConnection: { open: true, data: {} }, + }, + closeModal: vi.fn(), + openNewCredential: vi.fn(), + openExistingCredential: vi.fn(), + appliedTheme: 'light', + }, +})); + +vi.mock('../../../instanceAiMcp.telemetry', () => ({ + useInstanceAiMcpTelemetry: () => telemetryMock, +})); + +vi.mock('@/app/stores/ui.store', () => ({ + useUIStore: () => uiStoreMock, +})); + +vi.mock('@/features/credentials/credentials.store', () => ({ + useCredentialsStore: () => ({ + fetchAllCredentials: vi.fn().mockResolvedValue([]), + getCredentialsByType: vi.fn().mockReturnValue([]), + }), +})); + +vi.mock('@/features/credentials/composables/useCredentialOAuth', () => ({ + useCredentialOAuth: () => ({ + canOAuthCredentialQuickConnect: vi.fn().mockReturnValue(false), + createAndAuthorize: vi.fn(), + }), +})); + +vi.mock('@/app/composables/useToast', () => ({ + useToast: () => ({ + showMessage: vi.fn(), + showError: vi.fn(), + }), +})); + +const linearItem: McpServerConnectionItem = { + id: 'linear', + kind: 'mcp-server', + title: 'Linear', + isConnected: false, + credentials: [{ authType: 'mcpOAuth2Api', required: true }], + availableTools: [], +}; + +const connectedLinearItem: McpServerConnectionItem = { + ...linearItem, + id: 'conn-1', + isConnected: true, + credentials: [{ authType: 'mcpOAuth2Api', credentialId: 'cred-1', required: true }], +}; + +let modalListeners: Record = {}; + +const ToolsConnectionModalStub = defineComponent({ + name: 'ToolsConnectionModal', + inheritAttrs: false, + setup(_, { attrs }) { + modalListeners = attrs; + return {}; + }, + template: '
', +}); + +function emitModalEvent(eventName: string, ...args: Args): void { + const listener = modalListeners[eventName]; + if (typeof listener !== 'function') { + throw new Error(`Missing modal listener: ${eventName}`); + } + + (listener as (...listenerArgs: Args) => void)(...args); +} + +function emitSave(settings: ToolConnectionSettings): void { + emitModalEvent('onSave', connectedLinearItem, settings); +} + +function emitSelectCredential(): void { + emitModalEvent('onSelectCredential', linearItem, 'mcpOAuth2Api', 'cred-1'); +} + +function emitFirstCredentialConnect(): void { + emitModalEvent('onFirstCredentialConnect', linearItem); +} + +function emitCredentialDropdownOpen(): void { + emitModalEvent('onCredentialDropdownOpen', linearItem); +} + +function emitNewCredentialConnect(): void { + emitModalEvent('onNewCredentialConnect', linearItem); +} + +const renderComponent = createComponentRenderer(InstanceAiToolsConnectionModalWrapper, { + props: { modalName: 'instanceAiToolsConnection' }, + global: { + stubs: { + ToolsConnectionModal: ToolsConnectionModalStub, + }, + }, +}); + +describe('InstanceAiToolsConnectionModalWrapper', () => { + beforeEach(() => { + vi.clearAllMocks(); + modalListeners = {}; + mcpStoreMock.connections = []; + mcpStoreMock.catalog = [ + { + slug: 'linear', + title: 'Linear', + tagline: 'Linear MCP', + description: 'Linear MCP', + credentialType: 'mcpOAuth2Api', + tools: [], + icons: [], + isOfficial: true, + version: '1.0.0', + websiteUrl: 'https://linear.app', + }, + ]; + mcpStoreMock.connectionsByServerSlug = new Map(); + mcpStoreMock.connectionToolsById = new Map(); + mockConnect.mockResolvedValue(null); + mockUpdateConnection.mockResolvedValue({ serverSlug: 'linear' }); + }); + + it('tracks tool filter settings after a successful save', async () => { + renderComponent(); + + emitSave({ + inclusionMode: 'selected', + selectedTools: ['search'], + excludedTools: [], + }); + await flushPromises(); + + expect(mockUpdateConnection).toHaveBeenCalledWith('conn-1', { + inclusionMode: 'selected', + selectedTools: ['search'], + excludedTools: [], + }); + expect(telemetryMock.trackToolFilterSettingsUpdated).toHaveBeenCalledWith('linear', 'selected'); + }); + + it('tracks first credential connection start', () => { + renderComponent(); + + emitFirstCredentialConnect(); + + expect(telemetryMock.trackFirstCredentialConnectionStart).toHaveBeenCalledWith('linear'); + }); + + it('tracks credential dropdown opening', () => { + renderComponent(); + + emitCredentialDropdownOpen(); + + expect(telemetryMock.trackCredentialDropdownOpened).toHaveBeenCalledWith('linear'); + }); + + it('tracks existing credential selection', async () => { + renderComponent(); + + emitSelectCredential(); + await flushPromises(); + + expect(telemetryMock.trackExistingCredentialSelected).toHaveBeenCalledWith('linear'); + }); + + it('tracks new credential connection start', () => { + renderComponent(); + + emitNewCredentialConnect(); + + expect(telemetryMock.trackNewCredentialConnectionStart).toHaveBeenCalledWith('linear'); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiMcp.telemetry.ts b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiMcp.telemetry.ts index 0004f55622d..5a8bc98f6e5 100644 --- a/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiMcp.telemetry.ts +++ b/packages/frontend/editor-ui/src/features/ai/instanceAi/instanceAiMcp.telemetry.ts @@ -1,13 +1,40 @@ import { useTelemetry } from '@/app/composables/useTelemetry'; +import type { McpToolInclusionMode } from '@/features/shared/toolsConnection/types'; export function useInstanceAiMcpTelemetry() { const telemetry = useTelemetry(); return { - trackModalOpened() { - telemetry.track('Instance AI mcp modal opened'); + trackToolsListOpened() { + telemetry.track('Instance AI tools list opened'); }, trackSettingsOpened(serverSlug: string) { telemetry.track('Instance AI mcp settings opened', { server_slug: serverSlug }); }, + trackFirstCredentialConnectionStart(serverSlug: string) { + telemetry.track('Instance AI mcp first credential connection start', { + server_slug: serverSlug, + }); + }, + trackCredentialDropdownOpened(serverSlug: string) { + telemetry.track('Instance AI mcp credential dropdown opened', { + server_slug: serverSlug, + }); + }, + trackExistingCredentialSelected(serverSlug: string) { + telemetry.track('Instance AI mcp existing credential selected', { + server_slug: serverSlug, + }); + }, + trackNewCredentialConnectionStart(serverSlug: string) { + telemetry.track('Instance AI mcp new credential connection start', { + server_slug: serverSlug, + }); + }, + trackToolFilterSettingsUpdated(serverSlug: string, inclusionMode: McpToolInclusionMode) { + telemetry.track('Instance AI mcp tool filter settings updated', { + server_slug: serverSlug, + inclusion_mode: inclusionMode, + }); + }, }; } diff --git a/packages/frontend/editor-ui/src/features/shared/toolsConnection/ToolCredentialPicker.vue b/packages/frontend/editor-ui/src/features/shared/toolsConnection/ToolCredentialPicker.vue index 503a41d30b1..4baeff2e39f 100644 --- a/packages/frontend/editor-ui/src/features/shared/toolsConnection/ToolCredentialPicker.vue +++ b/packages/frontend/editor-ui/src/features/shared/toolsConnection/ToolCredentialPicker.vue @@ -21,6 +21,9 @@ const props = withDefaults( const emit = defineEmits<{ 'select-credential': [item: ToolConnectionItem, authType: string, credentialId: string]; + 'credential-dropdown-open': [item: ToolConnectionItem]; + 'first-credential-connect': [item: ToolConnectionItem]; + 'new-credential-connect': [item: ToolConnectionItem]; }>(); const i18n = useI18n(); @@ -56,6 +59,7 @@ const filteredCredentials = computed(() => { watch(isOpen, (open) => { if (open) { + emit('credential-dropdown-open', props.item); searchQuery.value = ''; void nextTick(() => { (searchInputRef.value?.$el as HTMLElement | undefined) @@ -74,8 +78,13 @@ const createAuthType = computed( () => props.credentials.find((c) => c.required)?.authType ?? props.credentials[0]?.authType, ); -function createCredential() { +function createCredential(source: 'direct' | 'dropdown') { if (!createAuthType.value) return; + if (source === 'direct') { + emit('first-credential-connect', props.item); + } else { + emit('new-credential-connect', props.item); + } adapter?.openNewCredential(createAuthType.value); isOpen.value = false; } @@ -173,7 +182,7 @@ function editCredential(credentialId: string) { type="button" :class="$style.createRow" data-test-id="tool-credential-picker-create" - @click="createCredential" + @click="createCredential('dropdown')" > {{ i18n.baseText('tools.connection.credentialPicker.create') }} @@ -185,7 +194,7 @@ function editCredential(credentialId: string) { :variant="connectVariant" size="small" data-test-id="tool-credential-picker-trigger-connect" - @click="createCredential" + @click="createCredential('direct')" > {{ i18n.baseText('tools.connection.action.connect') }} diff --git a/packages/frontend/editor-ui/src/features/shared/toolsConnection/ToolDetailView.vue b/packages/frontend/editor-ui/src/features/shared/toolsConnection/ToolDetailView.vue index 79d0f94d1ae..58a87890811 100644 --- a/packages/frontend/editor-ui/src/features/shared/toolsConnection/ToolDetailView.vue +++ b/packages/frontend/editor-ui/src/features/shared/toolsConnection/ToolDetailView.vue @@ -17,6 +17,9 @@ const emit = defineEmits<{ back: []; close: []; 'select-credential': [item: ToolConnectionItem, authType: string, credentialId: string]; + 'credential-dropdown-open': [item: ToolConnectionItem]; + 'first-credential-connect': [item: ToolConnectionItem]; + 'new-credential-connect': [item: ToolConnectionItem]; }>(); const i18n = useI18n(); @@ -76,6 +79,9 @@ const resolvedIcon = computed(() => resolveToolItemIcon(props.item)); (toolItem, authType, credentialId) => emit('select-credential', toolItem, authType, credentialId) " + @credential-dropdown-open="emit('credential-dropdown-open', $event)" + @first-credential-connect="emit('first-credential-connect', $event)" + @new-credential-connect="emit('new-credential-connect', $event)" /> (); const i18n = useI18n(); @@ -56,6 +59,9 @@ function handleRowClick() { function handleConnect() { emit('connect', props.item); + if (props.item.credentials?.length) { + emit('first-credential-connect', props.item); + } } @@ -115,6 +121,9 @@ function handleConnect() { (toolItem, authType, credentialId) => emit('select-credential', toolItem, authType, credentialId) " + @credential-dropdown-open="emit('credential-dropdown-open', $event)" + @first-credential-connect="emit('first-credential-connect', $event)" + @new-credential-connect="emit('new-credential-connect', $event)" />