refactor: Simplify agent request store (#15743)

This commit is contained in:
Benjamin Schroth
2025-06-02 14:21:49 +02:00
committed by GitHub
parent 61d0c6a6e7
commit 62d70f5225
7 changed files with 181 additions and 323 deletions

View File

@@ -0,0 +1,7 @@
import '@testing-library/jest-dom';
import { configure } from '@testing-library/vue';
// Avoid tests failing because of difference between local and GitHub actions timezone
process.env.TZ = 'UTC';
configure({ testIdAttribute: 'data-test-id' });

View File

@@ -2,208 +2,180 @@ import { setActivePinia, createPinia } from 'pinia';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { nextTick } from 'vue';
import { useAgentRequestStore } from './useAgentRequestStore';
import {
type IAgentRequestStoreState,
type IAgentRequest,
useAgentRequestStore,
} from './useAgentRequestStore';
// Mock localStorage
const localStorageMock = {
getItem: vi.fn(),
setItem: vi.fn(),
clear: vi.fn(),
};
let mockLocalStorageValue: IAgentRequestStoreState = {};
Object.defineProperty(window, 'localStorage', {
value: localStorageMock,
writable: true,
});
const NODE_ID_1 = '123e4567-e89b-12d3-a456-426614174000';
const NODE_ID_2 = '987fcdeb-51a2-43d7-b654-987654321000';
const NODE_ID_3 = '456abcde-f789-12d3-a456-426614174000';
describe('parameterOverrides.store', () => {
vi.mock('@vueuse/core', () => ({
useLocalStorage: vi.fn((_key, defaultValue) => {
if (Object.keys(mockLocalStorageValue).length === 0) {
Object.assign(mockLocalStorageValue, structuredClone(defaultValue));
}
return {
value: mockLocalStorageValue,
};
}),
}));
describe('agentRequest.store', () => {
beforeEach(() => {
mockLocalStorageValue = {};
setActivePinia(createPinia());
localStorageMock.getItem.mockReset();
localStorageMock.setItem.mockReset();
localStorageMock.clear.mockReset();
});
describe('Initialization', () => {
it('initializes with empty state when localStorage is empty', () => {
localStorageMock.getItem.mockReturnValue(null);
const store = useAgentRequestStore();
expect(store.agentRequests).toEqual({});
expect(store.agentRequests.value).toEqual({});
});
it('initializes with data from localStorage', () => {
const mockData = {
const mockData: IAgentRequestStoreState = {
'workflow-1': {
'node-1': { param1: 'value1' },
[NODE_ID_1]: { query: { param1: 'value1' } },
},
};
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
const store = useAgentRequestStore();
expect(store.agentRequests).toEqual(mockData);
});
mockLocalStorageValue = mockData;
it('handles localStorage errors gracefully', () => {
localStorageMock.getItem.mockImplementation(() => {
throw new Error('Storage error');
});
const store = useAgentRequestStore();
expect(store.agentRequests).toEqual({});
expect(store.agentRequests.value).toEqual(mockData);
});
});
describe('Getters', () => {
it('gets parameter overrides for a node', () => {
const mockData = {
'workflow-1': {
'node-1': { param1: 'value1', param2: 'value2' },
},
};
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
const store = useAgentRequestStore();
const overrides = store.getAgentRequests('workflow-1', 'node-1');
store.setAgentRequestForNode('workflow-1', NODE_ID_1, {
query: { param1: 'value1', param2: 'value2' },
});
const overrides = store.getAgentRequests('workflow-1', NODE_ID_1);
expect(overrides).toEqual({ param1: 'value1', param2: 'value2' });
});
it('returns empty object for non-existent workflow/node', () => {
const store = useAgentRequestStore();
const overrides = store.getAgentRequests('non-existent', 'node-1');
const overrides = store.getAgentRequests('non-existent', NODE_ID_1);
expect(overrides).toEqual({});
});
it('gets a specific parameter override', () => {
const mockData = {
'workflow-1': {
'node-1': { param1: 'value1', param2: 'value2' },
},
};
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
const store = useAgentRequestStore();
store.setAgentRequestForNode('workflow-1', NODE_ID_1, {
query: { param1: 'value1', param2: 'value2' },
});
const override = store.getAgentRequest('workflow-1', 'node-1', 'param1');
const override = store.getQueryValue('workflow-1', NODE_ID_1, 'param1');
expect(override).toBe('value1');
});
it('returns undefined for non-existent parameter', () => {
const mockData = {
'workflow-1': {
'node-1': { param1: 'value1' },
},
};
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
const store = useAgentRequestStore();
store.setAgentRequestForNode('workflow-1', NODE_ID_1, {
query: { param1: 'value1' },
});
const override = store.getAgentRequest('workflow-1', 'node-1', 'non-existent');
const override = store.getQueryValue('workflow-1', NODE_ID_1, 'non-existent');
expect(override).toBeUndefined();
});
it('handles string query type', () => {
const store = useAgentRequestStore();
store.setAgentRequestForNode('workflow-1', NODE_ID_1, {
query: 'string-query',
});
const query = store.getAgentRequests('workflow-1', NODE_ID_1);
expect(query).toBe('string-query');
});
});
describe('Actions', () => {
it('adds a parameter override', () => {
it('sets parameter overrides for a node', () => {
const store = useAgentRequestStore();
store.addAgentRequest('workflow-1', 'node-1', 'param1', 'value1');
expect(store.agentRequests['workflow-1']['node-1'].param1).toBe('value1');
});
it('adds multiple parameter overrides', () => {
const store = useAgentRequestStore();
store.addAgentRequests('workflow-1', 'node-1', {
param1: 'value1',
param2: 'value2',
store.setAgentRequestForNode('workflow-1', NODE_ID_1, {
query: { param1: 'value1', param2: 'value2' },
});
expect(store.agentRequests['workflow-1']['node-1']).toEqual({
expect(
(store.agentRequests.value['workflow-1'] as unknown as { [key: string]: IAgentRequest })[
NODE_ID_1
].query,
).toEqual({
param1: 'value1',
param2: 'value2',
});
});
it('clears parameter overrides for a node', () => {
const mockData = {
'workflow-1': {
'node-1': { param1: 'value1', param2: 'value2' },
'node-2': { param3: 'value3' },
},
};
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
const store = useAgentRequestStore();
store.setAgentRequestForNode('workflow-1', NODE_ID_1, {
query: { param1: 'value1', param2: 'value2' },
});
store.setAgentRequestForNode('workflow-1', NODE_ID_2, {
query: { param3: 'value3' },
});
store.clearAgentRequests('workflow-1', 'node-1');
store.clearAgentRequests('workflow-1', NODE_ID_1);
expect(store.agentRequests['workflow-1']['node-1']).toEqual({});
expect(store.agentRequests['workflow-1']['node-2']).toEqual({ param3: 'value3' });
expect(
(store.agentRequests.value['workflow-1'] as unknown as { [key: string]: IAgentRequest })[
NODE_ID_1
].query,
).toEqual({});
expect(
(store.agentRequests.value['workflow-1'] as unknown as { [key: string]: IAgentRequest })[
NODE_ID_2
].query,
).toEqual({
param3: 'value3',
});
});
it('clears all parameter overrides for a workflow', () => {
const mockData = {
'workflow-1': {
'node-1': { param1: 'value1' },
'node-2': { param2: 'value2' },
},
'workflow-2': {
'node-3': { param3: 'value3' },
},
};
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
const store = useAgentRequestStore();
store.setAgentRequestForNode('workflow-1', NODE_ID_1, {
query: { param1: 'value1' },
});
store.setAgentRequestForNode('workflow-1', NODE_ID_2, {
query: { param2: 'value2' },
});
store.setAgentRequestForNode('workflow-2', NODE_ID_3, {
query: { param3: 'value3' },
});
store.clearAllAgentRequests('workflow-1');
expect(store.agentRequests['workflow-1']).toEqual({});
expect(store.agentRequests['workflow-2']).toEqual({
'node-3': { param3: 'value3' },
expect(store.agentRequests.value['workflow-1']).toEqual({});
expect(store.agentRequests.value['workflow-2']).toEqual({
[NODE_ID_3]: { query: { param3: 'value3' } },
});
});
it('clears all parameter overrides when no workflowId is provided', () => {
const mockData = {
'workflow-1': {
'node-1': { param1: 'value1' },
},
'workflow-2': {
'node-2': { param2: 'value2' },
},
};
localStorageMock.getItem.mockReturnValue(JSON.stringify(mockData));
const store = useAgentRequestStore();
store.setAgentRequestForNode('workflow-1', NODE_ID_1, {
query: { param1: 'value1' },
});
store.setAgentRequestForNode('workflow-2', NODE_ID_2, {
query: { param2: 'value2' },
});
store.clearAllAgentRequests();
expect(store.agentRequests).toEqual({});
});
});
describe('generateAgentRequest', () => {
it('generateAgentRequest', () => {
const store = useAgentRequestStore();
store.addAgentRequests('workflow-1', 'id1', {
param1: 'override1',
'parent.child': 'override2',
'parent.array[0].value': 'overrideArray1',
'parent.array[1].value': 'overrideArray2',
});
const result = store.generateAgentRequest('workflow-1', 'id1');
expect(result).toEqual({
param1: 'override1',
parent: {
child: 'override2',
array: [
{
value: 'overrideArray1',
},
{
value: 'overrideArray2',
},
],
},
});
expect(store.agentRequests.value).toEqual({});
});
});
@@ -211,37 +183,17 @@ describe('parameterOverrides.store', () => {
it('saves to localStorage when state changes', async () => {
const store = useAgentRequestStore();
localStorageMock.setItem.mockReset();
store.addAgentRequest('workflow-1', 'node-1', 'param1', 'value1');
// Wait for the next tick to allow the watch to execute
await nextTick();
expect(localStorageMock.setItem).toHaveBeenCalledWith(
'n8n-agent-requests',
JSON.stringify({
'workflow-1': {
'node-1': { param1: 'value1' },
},
}),
);
});
it('should handle localStorage errors when saving', async () => {
const store = useAgentRequestStore();
localStorageMock.setItem.mockReset();
localStorageMock.setItem.mockImplementation(() => {
throw new Error('Storage error');
store.setAgentRequestForNode('workflow-1', NODE_ID_1, {
query: { param1: 'value1' },
});
store.addAgentRequest('workflow-1', 'node-1', 'param1', 'value1');
await nextTick();
expect(store.agentRequests['workflow-1']['node-1'].param1).toBe('value1');
expect(mockLocalStorageValue).toEqual({
'workflow-1': {
[NODE_ID_1]: { query: { param1: 'value1' } },
},
});
});
});
});

View File

@@ -1,41 +1,23 @@
import { useLocalStorage } from '@vueuse/core';
import type { INodeParameters, NodeParameterValueType } from 'n8n-workflow';
import { defineStore } from 'pinia';
import { ref, watch } from 'vue';
interface IAgentRequestStoreState {
const LOCAL_STORAGE_AGENT_REQUESTS = 'N8N_AGENT_REQUESTS';
export interface IAgentRequest {
query: INodeParameters | string;
toolName?: string;
}
export interface IAgentRequestStoreState {
[workflowId: string]: {
[nodeName: string]: INodeParameters;
[nodeName: string]: IAgentRequest;
};
}
const STORAGE_KEY = 'n8n-agent-requests';
export const useAgentRequestStore = defineStore('agentRequest', () => {
// State
const agentRequests = ref<IAgentRequestStoreState>(loadFromLocalStorage());
// Load initial state from localStorage
function loadFromLocalStorage(): IAgentRequestStoreState {
try {
const storedData = localStorage.getItem(STORAGE_KEY);
return storedData ? JSON.parse(storedData) : {};
} catch (error) {
return {};
}
}
// Save state to localStorage whenever it changes
watch(
agentRequests,
(newValue) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(newValue));
} catch (error) {
console.error('Failed to save agent requests to localStorage:', error);
}
},
{ deep: true },
);
const agentRequests = useLocalStorage<IAgentRequestStoreState>(LOCAL_STORAGE_AGENT_REQUESTS, {});
// Helper function to ensure workflow and node entries exist
const ensureWorkflowAndNodeExist = (workflowId: string, nodeId: string): void => {
@@ -44,167 +26,66 @@ export const useAgentRequestStore = defineStore('agentRequest', () => {
}
if (!agentRequests.value[workflowId][nodeId]) {
agentRequests.value[workflowId][nodeId] = {};
agentRequests.value[workflowId][nodeId] = { query: {} };
}
};
// Getters
const getAgentRequests = (workflowId: string, nodeId: string): INodeParameters => {
return agentRequests.value[workflowId]?.[nodeId] || {};
const getAgentRequests = (workflowId: string, nodeId: string): INodeParameters | string => {
return agentRequests.value[workflowId]?.[nodeId]?.query || {};
};
const getAgentRequest = (
const getQueryValue = (
workflowId: string,
nodeId: string,
paramName: string,
): NodeParameterValueType | undefined => {
return agentRequests.value[workflowId]?.[nodeId]?.[paramName];
const query = agentRequests.value[workflowId]?.[nodeId]?.query;
if (typeof query === 'string') {
return undefined;
}
return query?.[paramName] as NodeParameterValueType;
};
// Actions
const addAgentRequest = (
const setAgentRequestForNode = (
workflowId: string,
nodeId: string,
paramName: string,
paramValues: NodeParameterValueType,
): INodeParameters => {
request: IAgentRequest,
): void => {
ensureWorkflowAndNodeExist(workflowId, nodeId);
agentRequests.value[workflowId][nodeId] = {
...agentRequests.value[workflowId][nodeId],
[paramName]: paramValues,
};
return agentRequests.value[workflowId][nodeId];
};
const addAgentRequests = (workflowId: string, nodeId: string, params: INodeParameters): void => {
ensureWorkflowAndNodeExist(workflowId, nodeId);
agentRequests.value[workflowId][nodeId] = {
...agentRequests.value[workflowId][nodeId],
...params,
...request,
query: typeof request.query === 'string' ? request.query : { ...request.query },
};
};
const clearAgentRequests = (workflowId: string, nodeId: string): void => {
if (agentRequests.value[workflowId]) {
agentRequests.value[workflowId][nodeId] = {};
agentRequests.value[workflowId][nodeId] = { query: {} };
}
};
const clearAllAgentRequests = (workflowId?: string): void => {
if (workflowId) {
// Clear requests for a specific workflow
agentRequests.value[workflowId] = {};
} else {
// Clear all requests
agentRequests.value = {};
}
};
function parsePath(path: string): string[] {
return path.split('.').reduce((acc: string[], part) => {
if (part.includes('[')) {
const [arrayName, index] = part.split('[');
if (arrayName) acc.push(arrayName);
if (index) acc.push(index.replace(']', ''));
} else {
acc.push(part);
}
return acc;
}, []);
}
function buildRequestObject(path: string[], value: NodeParameterValueType): INodeParameters {
const result: INodeParameters = {};
let current = result;
for (let i = 0; i < path.length - 1; i++) {
const part = path[i];
const nextPart = path[i + 1];
const isArrayIndex = nextPart && !isNaN(Number(nextPart));
if (isArrayIndex) {
if (!current[part]) {
current[part] = [];
}
while ((current[part] as NodeParameterValueType[]).length <= Number(nextPart)) {
(current[part] as NodeParameterValueType[]).push({});
}
} else if (!current[part]) {
current[part] = {};
}
current = current[part] as INodeParameters;
}
current[path[path.length - 1]] = value;
return result;
}
// Helper function to deep merge objects
function deepMerge(target: INodeParameters, source: INodeParameters): INodeParameters {
const result = { ...target };
for (const key in source) {
if (source[key] && typeof source[key] === 'object' && !Array.isArray(source[key])) {
// Recursively merge nested objects
result[key] = deepMerge(
(result[key] as INodeParameters) || {},
source[key] as INodeParameters,
);
} else if (Array.isArray(source[key])) {
// For arrays, merge by index
if (Array.isArray(result[key])) {
const targetArray = result[key] as NodeParameterValueType[];
const sourceArray = source[key] as NodeParameterValueType[];
// Ensure target array has enough elements
while (targetArray.length < sourceArray.length) {
targetArray.push({});
}
// Merge each array item
sourceArray.forEach((item, index) => {
if (item && typeof item === 'object') {
targetArray[index] = deepMerge(
(targetArray[index] as INodeParameters) || {},
item as INodeParameters,
) as NodeParameterValueType;
} else {
targetArray[index] = item;
}
});
} else {
result[key] = source[key];
}
} else {
// For primitive values, use source value
result[key] = source[key];
}
}
return result;
}
const generateAgentRequest = (workflowId: string, nodeId: string): INodeParameters => {
const nodeRequests = agentRequests.value[workflowId]?.[nodeId] || {};
return Object.entries(nodeRequests).reduce(
(acc, [path, value]) => deepMerge(acc, buildRequestObject(parsePath(path), value)),
{} as INodeParameters,
);
const getAgentRequest = (workflowId: string, nodeId: string): IAgentRequest | undefined => {
if (agentRequests.value[workflowId]) return agentRequests.value[workflowId]?.[nodeId];
return undefined;
};
return {
agentRequests,
getAgentRequests,
getAgentRequest,
addAgentRequest,
addAgentRequests,
getQueryValue,
setAgentRequestForNode,
clearAgentRequests,
clearAllAgentRequests,
generateAgentRequest,
getAgentRequest,
};
});

View File

@@ -124,8 +124,8 @@ describe('FromAiParametersModal', () => {
workflowsStore.getCurrentWorkflow = vi.fn().mockReturnValue(mockWorkflow);
agentRequestStore = useAgentRequestStore();
agentRequestStore.clearAgentRequests = vi.fn();
agentRequestStore.addAgentRequests = vi.fn();
agentRequestStore.generateAgentRequest = vi.fn();
agentRequestStore.setAgentRequestForNode = vi.fn();
agentRequestStore.getAgentRequest = vi.fn();
nodeTypesStore = useNodeTypesStore();
nodeTypesStore.getNodeParameterOptions = vi.fn().mockResolvedValue(mockTools);
});
@@ -214,9 +214,11 @@ describe('FromAiParametersModal', () => {
await userEvent.click(getByTestId('execute-workflow-button'));
expect(agentRequestStore.addAgentRequests).toHaveBeenCalledWith('test-workflow', 'id1', {
'query.testBoolean': true,
'query.testParam': 'override',
expect(agentRequestStore.setAgentRequestForNode).toHaveBeenCalledWith('test-workflow', 'id1', {
query: {
testBoolean: true,
testParam: 'override',
},
});
});
@@ -266,9 +268,11 @@ describe('FromAiParametersModal', () => {
);
await userEvent.click(getByTestId('execute-workflow-button'));
expect(agentRequestStore.addAgentRequests).toHaveBeenCalledWith('test-workflow', 'id1', {
'query.testBoolean': false,
'query.testParam': 'given value',
expect(agentRequestStore.setAgentRequestForNode).toHaveBeenCalledWith('test-workflow', 'id1', {
query: {
testBoolean: false,
testParam: 'given value',
},
});
});
});

View File

@@ -2,7 +2,7 @@
import { useI18n } from '@n8n/i18n';
import { useRunWorkflow } from '@/composables/useRunWorkflow';
import { FROM_AI_PARAMETERS_MODAL_KEY, AI_MCP_TOOL_NODE_TYPE } from '@/constants';
import { useAgentRequestStore } from '@n8n/stores/useAgentRequestStore';
import { useAgentRequestStore, type IAgentRequest } from '@n8n/stores/useAgentRequestStore';
import { useWorkflowsStore } from '@/stores/workflows.store';
import { createEventBus } from '@n8n/utils/event-bus';
import {
@@ -166,11 +166,8 @@ watch(
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);
: (agentRequestStore.getQueryValue(workflowsStore.workflowId, newNode.id, value.key) ??
mapTypes[type]?.defaultValue);
result.push({
name: 'query.' + value.key,
@@ -189,7 +186,7 @@ watch(
}
const queryValue =
inputQuery ??
agentRequestStore.getAgentRequest(workflowsStore.workflowId, newNode.id, 'query') ??
agentRequestStore.getQueryValue(workflowsStore.workflowId, newNode.id, 'query') ??
'';
result.push({
@@ -216,7 +213,24 @@ const onExecute = async () => {
const inputValues = inputs.value?.getValues() ?? {};
agentRequestStore.clearAgentRequests(workflowsStore.workflowId, node.value.id);
agentRequestStore.addAgentRequests(workflowsStore.workflowId, node.value.id, inputValues);
// Structure the input values as IAgentRequest
const agentRequest: IAgentRequest = {
query: {},
toolName: inputValues.toolName as string,
};
// Move all query.* fields to query object
Object.entries(inputValues).forEach(([key, value]) => {
if (key === 'query') {
agentRequest.query = value as string;
} else if (key.startsWith('query.') && 'string' !== typeof agentRequest.query) {
const queryKey = key.replace('query.', '');
agentRequest.query[queryKey] = value;
}
});
agentRequestStore.setAgentRequestForNode(workflowsStore.workflowId, node.value.id, agentRequest);
const telemetryPayload = {
node_type: node.value.type,

View File

@@ -68,7 +68,7 @@ vi.mock('@/stores/workflows.store', () => {
vi.mock('@/stores/parameterOverrides.store', () => {
const storeState: Partial<ReturnType<typeof useAgentRequestStore>> & {} = {
agentRequests: {},
generateAgentRequest: vi.fn(),
getAgentRequest: vi.fn(),
};
return {
useAgentRequestStore: vi.fn().mockReturnValue(storeState),
@@ -692,12 +692,12 @@ describe('useRunWorkflow({ router })', () => {
vi.mocked(workflowHelpers).getCurrentWorkflow.mockReturnValue(workflow);
vi.mocked(workflowHelpers).getWorkflowDataToSave.mockResolvedValue(workflowData);
vi.mocked(workflowsStore).getWorkflowRunData = mockRunData;
vi.mocked(agentRequestStore).generateAgentRequest.mockReturnValue(agentRequest);
vi.mocked(agentRequestStore).getAgentRequest.mockReturnValue(agentRequest);
// ACT
const result = await runWorkflow({ destinationNode: 'Test node' });
// ASSERT
expect(agentRequestStore.generateAgentRequest).toHaveBeenCalledWith('WorkflowId', 'Test id');
expect(agentRequestStore.getAgentRequest).toHaveBeenCalledWith('WorkflowId', 'Test id');
expect(workflowsStore.runWorkflow).toHaveBeenCalledWith({
agentRequest: {
query: 'query',

View File

@@ -314,7 +314,7 @@ export function useRunWorkflow(useRunWorkflowOpts: { router: ReturnType<typeof u
startRunData.destinationNode = options.destinationNode;
const nodeId = workflowsStore.getNodeByName(options.destinationNode as string)?.id;
if (workflow.id && nodeId && version === 2) {
const agentRequest = agentRequestStore.generateAgentRequest(workflow.id, nodeId);
const agentRequest = agentRequestStore.getAgentRequest(workflow.id, nodeId);
if (agentRequest) {
startRunData.agentRequest = {