feat(core): Implement partial execution for all tool nodes (#15168)

This commit is contained in:
Benjamin Schroth
2025-05-12 12:31:17 +02:00
committed by GitHub
parent d12c7ee87f
commit 8b467e3f56
39 changed files with 1129 additions and 279 deletions

View File

@@ -1,12 +1,14 @@
import { createTestingPinia } from '@pinia/testing';
import { createComponentRenderer } from '@/__tests__/render';
import FromAiParametersModal from '@/components/FromAiParametersModal.vue';
import { FROM_AI_PARAMETERS_MODAL_KEY, STORES } from '@/constants';
import { FROM_AI_PARAMETERS_MODAL_KEY, STORES, AI_MCP_TOOL_NODE_TYPE } from '@/constants';
import userEvent from '@testing-library/user-event';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { useParameterOverridesStore } from '@/stores/parameterOverrides.store';
import { useAgentRequestStore } from '@/stores/agentRequest.store';
import { useRouter } from 'vue-router';
import { NodeConnectionTypes } from 'n8n-workflow';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { nextTick } from 'vue';
const ModalStub = {
template: `
@@ -26,12 +28,20 @@ vi.mocked(useRouter);
const mockNode = {
id: 'id1',
name: 'Test Node',
type: 'n8n-nodes-base.ai-tool',
parameters: {
testBoolean: "={{ $fromAI('testBoolean', ``, 'boolean') }}",
testParam: "={{ $fromAi('testParam', ``, 'string') }}",
},
};
const mockMcpNode = {
id: 'id1',
name: 'Test MCP Node',
type: AI_MCP_TOOL_NODE_TYPE,
parameters: {},
};
const mockParentNode = {
name: 'Parent Node',
};
@@ -43,7 +53,7 @@ const mockRunData = {
['Test Node']: [
{
inputOverride: {
[NodeConnectionTypes.AiTool]: [[{ json: { testParam: 'override' } }]],
[NodeConnectionTypes.AiTool]: [[{ json: { query: { testParam: 'override' } } }]],
},
},
],
@@ -57,10 +67,27 @@ const mockWorkflow = {
getChildNodes: () => ['Parent Node'],
};
const mockTools = [
{
name: 'Test Tool',
value: 'test-tool',
inputSchema: {
properties: {
query: {
type: 'string',
description: 'Test query',
},
},
},
},
];
const renderModal = createComponentRenderer(FromAiParametersModal);
let pinia: ReturnType<typeof createTestingPinia>;
let workflowsStore: ReturnType<typeof useWorkflowsStore>;
let parameterOverridesStore: ReturnType<typeof useParameterOverridesStore>;
let agentRequestStore: ReturnType<typeof useAgentRequestStore>;
let nodeTypesStore: ReturnType<typeof useNodeTypesStore>;
describe('FromAiParametersModal', () => {
beforeEach(() => {
pinia = createTestingPinia({
@@ -83,14 +110,23 @@ describe('FromAiParametersModal', () => {
},
});
workflowsStore = useWorkflowsStore();
workflowsStore.getNodeByName = vi
.fn()
.mockImplementation((name: string) => (name === 'Test Node' ? mockNode : mockParentNode));
workflowsStore.getNodeByName = vi.fn().mockImplementation((name: string) => {
switch (name) {
case 'Test Node':
return mockNode;
case 'Test MCP Node':
return mockMcpNode;
default:
return mockParentNode;
}
});
workflowsStore.getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
parameterOverridesStore = useParameterOverridesStore();
parameterOverridesStore.clearParameterOverrides = vi.fn();
parameterOverridesStore.addParameterOverrides = vi.fn();
parameterOverridesStore.substituteParameters = vi.fn();
agentRequestStore = useAgentRequestStore();
agentRequestStore.clearAgentRequests = vi.fn();
agentRequestStore.addAgentRequests = vi.fn();
agentRequestStore.generateAgentRequest = vi.fn();
nodeTypesStore = useNodeTypesStore();
nodeTypesStore.getNodeParameterOptions = vi.fn().mockResolvedValue(mockTools);
});
it('renders correctly with node data', () => {
@@ -112,6 +148,53 @@ describe('FromAiParametersModal', () => {
expect(getByTitle('Test Test Node')).toBeTruthy();
});
it('shows tool selection for AI tool nodes', async () => {
const { findByRole } = renderModal({
props: {
modalName: FROM_AI_PARAMETERS_MODAL_KEY,
data: {
nodeName: 'Test MCP Node',
},
},
global: {
stubs: {
Modal: ModalStub,
},
},
pinia,
});
const toolSelect = await findByRole('combobox');
expect(toolSelect).toBeTruthy();
});
it('shows tool parameters after tool selection', async () => {
const { getByTestId, findByRole, findByText } = renderModal({
props: {
modalName: FROM_AI_PARAMETERS_MODAL_KEY,
data: {
nodeName: 'Test MCP Node',
},
},
global: {
stubs: {
Modal: ModalStub,
},
},
pinia,
});
const toolSelect = await findByRole('combobox');
await userEvent.click(toolSelect);
const toolOption = await findByText('Test Tool');
await userEvent.click(toolOption);
await nextTick();
const inputs = getByTestId('from-ai-parameters-modal-inputs');
const inputByName = inputs.querySelector('input[name="query.query"]');
expect(inputByName).toBeTruthy();
});
it('uses run data when available as initial values', async () => {
const { getByTestId } = renderModal({
props: {
@@ -130,14 +213,10 @@ describe('FromAiParametersModal', () => {
await userEvent.click(getByTestId('execute-workflow-button'));
expect(parameterOverridesStore.addParameterOverrides).toHaveBeenCalledWith(
'test-workflow',
'id1',
{
testBoolean: true,
testParam: 'override',
},
);
expect(agentRequestStore.addAgentRequests).toHaveBeenCalledWith('test-workflow', 'id1', {
'query.testBoolean': true,
'query.testParam': 'override',
});
});
it('clears parameter overrides when modal is executed', async () => {
@@ -158,13 +237,10 @@ describe('FromAiParametersModal', () => {
await userEvent.click(getByTestId('execute-workflow-button'));
expect(parameterOverridesStore.clearParameterOverrides).toHaveBeenCalledWith(
'test-workflow',
'id1',
);
expect(agentRequestStore.clearAgentRequests).toHaveBeenCalledWith('test-workflow', 'id1');
});
it('adds substitutes for parameters when executed', async () => {
it('adds agent request with given parameters when executed', async () => {
const { getByTestId } = renderModal({
props: {
modalName: FROM_AI_PARAMETERS_MODAL_KEY,
@@ -182,17 +258,16 @@ describe('FromAiParametersModal', () => {
const inputs = getByTestId('from-ai-parameters-modal-inputs');
await userEvent.click(inputs.querySelector('input[value="testBoolean"]') as Element);
await userEvent.clear(inputs.querySelector('input[name="testParam"]') as Element);
await userEvent.type(inputs.querySelector('input[name="testParam"]') as Element, 'given value');
await userEvent.clear(inputs.querySelector('input[name="query.testParam"]') as Element);
await userEvent.type(
inputs.querySelector('input[name="query.testParam"]') as Element,
'given value',
);
await userEvent.click(getByTestId('execute-workflow-button'));
expect(parameterOverridesStore.addParameterOverrides).toHaveBeenCalledWith(
'test-workflow',
'id1',
{
testBoolean: false,
testParam: 'given value',
},
);
expect(agentRequestStore.addAgentRequests).toHaveBeenCalledWith('test-workflow', 'id1', {
'query.testBoolean': false,
'query.testParam': 'given value',
});
});
});

View File

@@ -1,20 +1,23 @@
<script setup lang="ts">
import { useI18n } from '@/composables/useI18n';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { FROM_AI_PARAMETERS_MODAL_KEY } from '@/constants';
import { useParameterOverridesStore } from '@/stores/parameterOverrides.store';
import { FROM_AI_PARAMETERS_MODAL_KEY, AI_MCP_TOOL_NODE_TYPE } from '@/constants';
import { useAgentRequestStore } from '@/stores/agentRequest.store';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { createEventBus } from '@n8n/utils/event-bus';
import {
type FromAIArgument,
type IDataObject,
NodeConnectionTypes,
traverseNodeParametersWithParamNames,
traverseNodeParameters,
} from 'n8n-workflow';
import { computed, ref } from 'vue';
import type { IFormInput } from '@n8n/design-system';
import { computed, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { type IFormInput } from '@n8n/design-system';
import { useTelemetry } from '@/composables/useTelemetry';
import { useNDVStore } from '@/stores/ndv.store';
import { useNodeTypesStore } from '@/stores/nodeTypes.store';
import { type JSONSchema7 } from 'json-schema';
type Value = string | number | boolean | null | undefined;
@@ -31,9 +34,10 @@ const telemetry = useTelemetry();
const ndvStore = useNDVStore();
const modalBus = createEventBus();
const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const router = useRouter();
const { runWorkflow } = useRunWorkflow({ router });
const parameterOverridesStore = useParameterOverridesStore();
const agentRequestStore = useAgentRequestStore();
const node = computed(() =>
props.data.nodeName ? workflowsStore.getNodeByName(props.data.nodeName) : undefined,
@@ -47,6 +51,9 @@ const parentNode = computed(() => {
return workflowsStore.getNodeByName(parentNodes[0])?.name;
});
const parameters = ref<IFormInput[]>([]);
const selectedTool = ref<string>('');
const nodeRunData = computed(() => {
if (!node.value) return undefined;
@@ -80,38 +87,125 @@ const mapTypes: {
},
};
const parameters = computed(() => {
if (!node.value) return [];
watch(
[node, selectedTool],
async ([newNode, newSelectedTool]) => {
if (!newNode) {
parameters.value = [];
return;
}
const result: IFormInput[] = [];
const params = node.value.parameters;
const collectedArgs: Map<string, FromAIArgument> = new Map();
traverseNodeParametersWithParamNames(params, collectedArgs);
const inputOverrides =
nodeRunData.value?.inputOverride?.[NodeConnectionTypes.AiTool]?.[0]?.[0].json;
const result: IFormInput[] = [];
collectedArgs.forEach((value: FromAIArgument, paramName: string) => {
const type = value.type ?? 'string';
const initialValue = inputOverrides?.[value.key]
? inputOverrides[value.key]
: (parameterOverridesStore.getParameterOverride(
workflowsStore.workflowId,
node.value!.id,
paramName,
) ?? mapTypes[type]?.defaultValue);
// Handle MCPClientTool nodes differently
if (newNode.type === AI_MCP_TOOL_NODE_TYPE) {
const tools = await nodeTypesStore.getNodeParameterOptions({
nodeTypeAndVersion: {
name: newNode.type,
version: newNode.typeVersion,
},
path: 'parmeters.includedTools',
methodName: 'getTools',
currentNodeParameters: newNode.parameters,
});
result.push({
name: paramName,
initialValue: initialValue as string | number | boolean | null | undefined,
properties: {
label: value.key,
type: mapTypes[type].inputType,
required: true,
},
// Load available tools
const toolOptions = tools?.map((tool) => ({
label: tool.name,
value: String(tool.value),
disabled: false,
}));
result.push({
name: 'toolName',
initialValue: '',
properties: {
label: 'Tool name',
type: 'select',
options: toolOptions,
required: true,
},
});
// Only show parameters for selected tool
if (newSelectedTool) {
const selectedToolData = tools?.find((tool) => String(tool.value) === newSelectedTool);
const schema = selectedToolData?.inputSchema as JSONSchema7;
if (schema.properties) {
for (const [propertyName, value] of Object.entries(schema.properties)) {
const typedValue = value as {
type: string;
description: string;
};
result.push({
name: 'query.' + propertyName,
initialValue: '',
properties: {
label: propertyName,
type: mapTypes[typedValue.type ?? 'text'].inputType,
required: true,
},
});
}
}
}
parameters.value = result;
}
// Handle regular tool nodes
const params = newNode.parameters;
const collectedArgs: FromAIArgument[] = [];
traverseNodeParameters(params, collectedArgs);
const inputOverrides =
nodeRunData.value?.inputOverride?.[NodeConnectionTypes.AiTool]?.[0]?.[0].json;
collectedArgs.forEach((value: FromAIArgument) => {
const type = value.type ?? 'string';
const inputQuery = inputOverrides?.query as IDataObject;
const initialValue = inputQuery?.[value.key]
? inputQuery[value.key]
: (agentRequestStore.getAgentRequest(
workflowsStore.workflowId,
newNode.id,
'query.' + value.key,
) ?? mapTypes[type]?.defaultValue);
result.push({
name: 'query.' + value.key,
initialValue: initialValue as string | number | boolean | null | undefined,
properties: {
label: value.key,
type: mapTypes[value.type ?? 'string'].inputType,
required: true,
},
});
});
});
return result;
});
if (result.length === 0) {
let inputQuery = inputOverrides?.query;
if (typeof inputQuery === 'object') {
inputQuery = JSON.stringify(inputQuery);
}
const queryValue =
inputQuery ??
agentRequestStore.getAgentRequest(workflowsStore.workflowId, newNode.id, 'query') ??
'';
result.push({
name: 'query',
initialValue: (queryValue as string) ?? '',
properties: {
label: 'Query',
type: 'text',
required: true,
},
});
}
parameters.value = result;
},
{ immediate: true },
);
const onClose = () => {
modalBus.emit('close');
@@ -119,14 +213,10 @@ const onClose = () => {
const onExecute = async () => {
if (!node.value) return;
const inputValues = inputs.value!.getValues();
const inputValues = inputs.value?.getValues() ?? {};
parameterOverridesStore.clearParameterOverrides(workflowsStore.workflowId, node.value.id);
parameterOverridesStore.addParameterOverrides(
workflowsStore.workflowId,
node.value.id,
inputValues,
);
agentRequestStore.clearAgentRequests(workflowsStore.workflowId, node.value.id);
agentRequestStore.addAgentRequests(workflowsStore.workflowId, node.value.id, inputValues);
const telemetryPayload = {
node_type: node.value.type,
@@ -139,11 +229,16 @@ const onExecute = async () => {
await runWorkflow({
destinationNode: node.value.name,
source: 'RunData.TestExecuteModal',
});
onClose();
};
// Add handler for tool selection change
const onUpdate = (change: { name: string; value: string }) => {
if (change.name !== 'toolName') return;
selectedTool.value = change.value;
};
</script>
<template>
@@ -177,6 +272,7 @@ const onExecute = async () => {
:column-view="true"
data-test-id="from-ai-parameters-modal-inputs"
@submit="onExecute"
@update="onUpdate"
></N8nFormInputs>
</el-row>
</el-col>

View File

@@ -28,7 +28,7 @@ import { useI18n } from '@/composables/useI18n';
import { useTelemetry } from '@/composables/useTelemetry';
import { type IUpdateInformation } from '@/Interface';
import { generateCodeForAiTransform } from '@/components/ButtonParameter/utils';
import { hasFromAiExpressions } from '@/utils/nodes/nodeTransforms';
import { needsAgentInput } from '@/utils/nodes/nodeTransforms';
import { useUIStore } from '@/stores/ui.store';
const NODE_TEST_STEP_POPUP_COUNT_KEY = 'N8N_NODE_TEST_STEP_POPUP_COUNT';
@@ -345,7 +345,7 @@ async function onClick() {
}
if (!pinnedData.hasData.value || shouldUnpinAndExecute) {
if (node.value && hasFromAiExpressions(node.value)) {
if (node.value && needsAgentInput(node.value)) {
uiStore.openModalWithData({
name: FROM_AI_PARAMETERS_MODAL_KEY,
data: {

View File

@@ -130,9 +130,7 @@ const node = computed(() => ndvStore.activeNode);
const isTriggerNode = computed(() => !!node.value && nodeTypesStore.isTriggerNode(node.value.type));
const isNodesAsToolNode = computed(
() => !!node.value && nodeTypesStore.isNodesAsToolNode(node.value.type),
);
const isToolNode = computed(() => !!node.value && nodeTypesStore.isToolNode(node.value.type));
const isExecutable = computed(() => {
if (props.nodeType && node.value) {
@@ -146,7 +144,7 @@ const isExecutable = computed(() => {
if (
!inputNames.includes(NodeConnectionTypes.Main) &&
!isNodesAsToolNode.value &&
!isToolNode.value &&
!isTriggerNode.value
) {
return false;

View File

@@ -29,9 +29,7 @@ const workflowsStore = useWorkflowsStore();
const nodeTypesStore = useNodeTypesStore();
const node = computed(() => !!name.value && workflowsStore.getNodeByName(name.value));
const isNodesAsToolNode = computed(
() => !!node.value && nodeTypesStore.isNodesAsToolNode(node.value.type),
);
const isToolNode = computed(() => !!node.value && nodeTypesStore.isToolNode(node.value.type));
const nodeDisabledTitle = computed(() => {
return isDisabled.value ? i18n.baseText('node.enable') : i18n.baseText('node.disable');
@@ -51,7 +49,7 @@ const isExecuteNodeVisible = computed(() => {
!props.readOnly &&
render.value.type === CanvasNodeRenderType.Default &&
'configuration' in render.value.options &&
(!render.value.options.configuration || isNodesAsToolNode.value)
(!render.value.options.configuration || isToolNode.value)
);
});