diff --git a/CHANGELOG.md b/CHANGELOG.md index 467ad3db0b619..3cf42fc55ed0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,14 @@ +## [1.103.2](https://github.com/n8n-io/n8n/compare/n8n@1.103.1...n8n@1.103.2) (2025-07-22) + + +### Bug Fixes + +* **editor:** Fix trimPayloadToSize mutating original objects in AI assistant ([#17498](https://github.com/n8n-io/n8n/issues/17498)) ([7724343](https://github.com/n8n-io/n8n/commit/7724343a8d306def69655783b00562bae23f6adf)) +* **GitHub Document Loader Node:** Fix node loading issue ([#17494](https://github.com/n8n-io/n8n/issues/17494)) ([2e75ddf](https://github.com/n8n-io/n8n/commit/2e75ddfcbb6762fddec7fbcfdf7737bdd58391e7)) +* **OpenAi Node:** optional chaining for error handling in router ([#17412](https://github.com/n8n-io/n8n/issues/17412)) ([952f869](https://github.com/n8n-io/n8n/commit/952f8697b0dbdbc18557676adc6687fa05739500)) + + + ## [1.103.1](https://github.com/n8n-io/n8n/compare/n8n@1.103.0...n8n@1.103.1) (2025-07-16) diff --git a/package.json b/package.json index 3b243cfb46a35..21654253b54a4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "n8n-monorepo", - "version": "1.103.1", + "version": "1.103.2", "private": true, "engines": { "node": ">=22.16", diff --git a/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentGithubLoader/DocumentGithubLoader.node.ts b/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentGithubLoader/DocumentGithubLoader.node.ts index f12a33924321d..a25125df03a23 100644 --- a/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentGithubLoader/DocumentGithubLoader.node.ts +++ b/packages/@n8n/nodes-langchain/nodes/document_loaders/DocumentGithubLoader/DocumentGithubLoader.node.ts @@ -1,6 +1,8 @@ import { GithubRepoLoader } from '@langchain/community/document_loaders/web/github'; import type { TextSplitter } from '@langchain/textsplitters'; import { RecursiveCharacterTextSplitter } from '@langchain/textsplitters'; +import { logWrapper } from '@utils/logWrapper'; +import { getConnectionHintNoticeField } from '@utils/sharedFields'; import { NodeConnectionTypes, type INodeType, @@ -11,9 +13,6 @@ import { type INodeInputConfiguration, } from 'n8n-workflow'; -import { logWrapper } from '@utils/logWrapper'; -import { getConnectionHintNoticeField } from '@utils/sharedFields'; - function getInputs(parameters: IDataObject) { const inputs: INodeInputConfiguration[] = []; diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/router.test.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/router.test.ts new file mode 100644 index 0000000000000..1331fdd3f9602 --- /dev/null +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/router.test.ts @@ -0,0 +1,42 @@ +import { mockDeep } from 'jest-mock-extended'; +import type { IExecuteFunctions, INode } from 'n8n-workflow'; +import { NodeApiError } from 'n8n-workflow'; + +import * as audio from './audio'; +import { router } from './router'; + +describe('OpenAI router', () => { + const mockExecuteFunctions = mockDeep(); + const mockAudio = jest.spyOn(audio.transcribe, 'execute'); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should handle NodeApiError undefined error chaining', async () => { + const errorNode: INode = { + id: 'error-node-id', + name: 'ErrorNode', + type: 'test.error', + typeVersion: 1, + position: [100, 200], + parameters: {}, + }; + const nodeApiError = new NodeApiError( + errorNode, + { message: 'API error occurred', error: { error: { message: 'Rate limit exceeded' } } }, + { itemIndex: 0 }, + ); + + mockExecuteFunctions.getNodeParameter.mockImplementation((parameter) => + parameter === 'resource' ? 'audio' : 'transcribe', + ); + mockExecuteFunctions.getInputData.mockReturnValue([{ json: {} }]); + mockExecuteFunctions.getNode.mockReturnValue(errorNode); + mockExecuteFunctions.continueOnFail.mockReturnValue(false); + + mockAudio.mockRejectedValue(nodeApiError); + + await expect(router.call(mockExecuteFunctions)).rejects.toThrow(NodeApiError); + }); +}); diff --git a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/router.ts b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/router.ts index 2b64993e2d474..876dd88cb1299 100644 --- a/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/router.ts +++ b/packages/@n8n/nodes-langchain/nodes/vendors/OpenAi/actions/router.ts @@ -62,7 +62,7 @@ export async function router(this: IExecuteFunctions) { if (error instanceof NodeApiError) { // If the error is a rate limit error, we want to handle it differently - const errorCode: string | undefined = (error.cause as any).error?.error?.code; + const errorCode: string | undefined = (error.cause as any)?.error?.error?.code; if (errorCode) { const customErrorMessage = getCustomErrorMessage(errorCode); if (customErrorMessage) { diff --git a/packages/@n8n/nodes-langchain/package.json b/packages/@n8n/nodes-langchain/package.json index f47fb34b422d2..8dbdd52941829 100644 --- a/packages/@n8n/nodes-langchain/package.json +++ b/packages/@n8n/nodes-langchain/package.json @@ -1,6 +1,6 @@ { "name": "@n8n/n8n-nodes-langchain", - "version": "1.102.0", + "version": "1.102.1", "description": "", "main": "index.js", "scripts": { @@ -206,6 +206,7 @@ "generate-schema": "2.6.0", "html-to-text": "9.0.5", "https-proxy-agent": "catalog:", + "ignore": "^5.2.0", "js-tiktoken": "^1.0.12", "jsdom": "23.0.1", "langchain": "0.3.29", diff --git a/packages/cli/package.json b/packages/cli/package.json index c53bb98935f56..3b4c27e8c6913 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "n8n", - "version": "1.103.1", + "version": "1.103.2", "description": "n8n Workflow Automation Tool", "main": "dist/index", "types": "dist/index.d.ts", diff --git a/packages/frontend/editor-ui/package.json b/packages/frontend/editor-ui/package.json index 582127ead0e6e..d92178a26db97 100644 --- a/packages/frontend/editor-ui/package.json +++ b/packages/frontend/editor-ui/package.json @@ -1,6 +1,6 @@ { "name": "n8n-editor-ui", - "version": "1.103.0", + "version": "1.103.1", "description": "Workflow Editor UI for n8n", "main": "index.js", "type": "module", diff --git a/packages/frontend/editor-ui/src/composables/useAIAssistantHelpers.test.ts b/packages/frontend/editor-ui/src/composables/useAIAssistantHelpers.test.ts index 2ecf284701ab2..9b077ee8f0510 100644 --- a/packages/frontend/editor-ui/src/composables/useAIAssistantHelpers.test.ts +++ b/packages/frontend/editor-ui/src/composables/useAIAssistantHelpers.test.ts @@ -1,5 +1,10 @@ import { describe, it, expect } from 'vitest'; -import type { INode, IRunExecutionData, NodeConnectionType } from 'n8n-workflow'; +import { + deepCopy, + type INode, + type IRunExecutionData, + type NodeConnectionType, +} from 'n8n-workflow'; import { useAIAssistantHelpers } from './useAIAssistantHelpers'; import { createTestingPinia } from '@pinia/testing'; import { setActivePinia } from 'pinia'; @@ -569,7 +574,7 @@ describe('Trim Payload Size', () => { }); it('Should trim active node parameters in error helper payload', () => { - const payload = ERROR_HELPER_TEST_PAYLOAD; + const payload = deepCopy(ERROR_HELPER_TEST_PAYLOAD); aiAssistantHelpers.trimPayloadSize(payload); expect((payload.payload as ChatRequest.InitErrorHelper).node.parameters).toEqual({}); }); @@ -577,15 +582,18 @@ describe('Trim Payload Size', () => { it('Should trim all node parameters in support chat', () => { // Testing the scenario where only one trimming pass is needed // (payload is under the limit after removing all node parameters and execution data) - const payload: ChatRequest.RequestPayload = SUPPORT_CHAT_TEST_PAYLOAD; - const supportPayload: ChatRequest.InitSupportChat = - payload.payload as ChatRequest.InitSupportChat; + const payload: ChatRequest.RequestPayload = deepCopy(SUPPORT_CHAT_TEST_PAYLOAD); // Trimming to 4kb should be successful expect(() => aiAssistantHelpers.trimPayloadSize(payload, PAYLOAD_SIZE_FOR_1_PASS), ).not.toThrow(); - // All active node parameters should be removed + + // Get the modified payload + const supportPayload: ChatRequest.InitSupportChat = + payload.payload as ChatRequest.InitSupportChat; + + // All active node parameters should be removed in the payload expect(supportPayload?.context?.activeNodeInfo?.node?.parameters).toEqual({}); // Also, all node parameters in the workflow should be removed supportPayload.context?.currentWorkflow?.nodes?.forEach((node) => { @@ -606,14 +614,17 @@ describe('Trim Payload Size', () => { it('Should trim the whole context in support chat', () => { // Testing the scenario where both trimming passes are needed // (payload is over the limit after removing all node parameters and execution data) - const payload: ChatRequest.RequestPayload = SUPPORT_CHAT_TEST_PAYLOAD; - const supportPayload: ChatRequest.InitSupportChat = - payload.payload as ChatRequest.InitSupportChat; + const payload: ChatRequest.RequestPayload = deepCopy(SUPPORT_CHAT_TEST_PAYLOAD); // Trimming should be successful expect(() => aiAssistantHelpers.trimPayloadSize(payload, PAYLOAD_SIZE_FOR_2_PASSES), ).not.toThrow(); + + // Get the modified payload + const supportPayload: ChatRequest.InitSupportChat = + payload.payload as ChatRequest.InitSupportChat; + // The whole context object should be removed expect(supportPayload.context).not.toBeDefined(); }); @@ -622,4 +633,111 @@ describe('Trim Payload Size', () => { const payload = ERROR_HELPER_TEST_PAYLOAD; expect(() => aiAssistantHelpers.trimPayloadSize(payload, 0.2)).toThrow(); }); + + it('Should NOT modify the original objects when trimming payload', () => { + // Create a test payload to verify that the original objects are not mutated + const testNode = { + id: 'test-node', + name: 'Test Node', + type: 'test.node', + typeVersion: 1, + position: [0, 0] as [number, number], + parameters: { key: 'value', nested: { data: 'test' } }, + }; + + const workflowNode = { + id: 'workflow-node', + name: 'Workflow Node', + type: 'test.node', + typeVersion: 1, + position: [100, 100] as [number, number], + parameters: { param1: 'test1', param2: 'test2' }, + }; + + const errorNode = { + id: 'error-node', + name: 'Error Node', + type: 'test.node', + typeVersion: 1, + position: [200, 200] as [number, number], + parameters: { errorParam: 'errorValue' }, + }; + + const payload: ChatRequest.RequestPayload = { + sessionId: 'test-session', + payload: { + type: 'init-support-chat', + role: 'user', + user: { + firstName: 'Test User', + }, + question: 'test question', + context: { + activeNodeInfo: { + node: testNode, + }, + currentWorkflow: { + name: 'Test Workflow', + nodes: [workflowNode], + connections: {}, + active: false, + }, + executionData: { + runData: { + 'Test Node': [ + { + startTime: 1000, + executionTime: 100, + executionIndex: 0, + source: [], + executionStatus: 'success', + data: { main: [[{ json: {} }]] }, + }, + ], + }, + }, + }, + }, + }; + + // Create a shared reference to verify immutability + const sharedReference = { + activeNode: testNode, + workflowNode, + errorNode, + }; + + // Store original parameter values + const originalTestNodeParams = { ...testNode.parameters }; + const originalWorkflowNodeParams = { ...workflowNode.parameters }; + const originalErrorNodeParams = { ...errorNode.parameters }; + + // Verify parameters exist before trimming + expect(Object.keys(testNode.parameters).length).toBeGreaterThan(0); + expect(Object.keys(workflowNode.parameters).length).toBeGreaterThan(0); + expect(Object.keys(errorNode.parameters).length).toBeGreaterThan(0); + + // Call trimPayloadSize + aiAssistantHelpers.trimPayloadSize(payload, PAYLOAD_SIZE_FOR_1_PASS); + + // Check that the original objects have NOT been modified + expect(testNode.parameters).toEqual(originalTestNodeParams); + expect(workflowNode.parameters).toEqual(originalWorkflowNodeParams); + expect(errorNode.parameters).toEqual(originalErrorNodeParams); + + // The shared references should also remain unchanged + expect(sharedReference.activeNode.parameters).toEqual(originalTestNodeParams); + expect(sharedReference.workflowNode.parameters).toEqual(originalWorkflowNodeParams); + expect(sharedReference.errorNode.parameters).toEqual(originalErrorNodeParams); + + // But the payload itself should have been modified with empty parameters + const supportPayload = payload.payload as ChatRequest.InitSupportChat; + expect(supportPayload.context?.activeNodeInfo?.node?.parameters).toEqual({}); + expect( + supportPayload.context?.currentWorkflow?.nodes?.every( + (node) => Object.keys(node.parameters).length === 0, + ), + ).toBe(true); + expect(supportPayload.context?.executionData?.runData).toEqual({}); + }); }); diff --git a/packages/frontend/editor-ui/src/composables/useAIAssistantHelpers.ts b/packages/frontend/editor-ui/src/composables/useAIAssistantHelpers.ts index 79c41d8404999..f7713f07b1e7f 100644 --- a/packages/frontend/editor-ui/src/composables/useAIAssistantHelpers.ts +++ b/packages/frontend/editor-ui/src/composables/useAIAssistantHelpers.ts @@ -262,7 +262,10 @@ export const useAIAssistantHelpers = () => { payload: ChatRequest.RequestPayload, size = AI_ASSISTANT_MAX_CONTENT_LENGTH, ): void => { - const requestPayload = payload.payload; + // Create a deep copy to avoid mutating the original payload + const payloadCopy = deepCopy(payload); + const requestPayload = payloadCopy.payload; + // For support chat, remove parameters from the active node object and all nodes in the workflow if (requestPayload.type === 'init-support-chat') { if (requestPayload.context?.activeNodeInfo?.node) { @@ -285,7 +288,7 @@ export const useAIAssistantHelpers = () => { } } // If the payload is still too big, remove the whole context object - if (getRequestPayloadSize(payload) > size) { + if (getRequestPayloadSize(payloadCopy) > size) { requestPayload.context = undefined; } // For error helper, remove parameters from the active node object @@ -294,9 +297,12 @@ export const useAIAssistantHelpers = () => { requestPayload.node.parameters = {}; } // If the payload is still too big, throw an error that will be shown to the user - if (getRequestPayloadSize(payload) > size) { + if (getRequestPayloadSize(payloadCopy) > size) { throw new Error(locale.baseText('aiAssistant.payloadTooBig.message')); } + + // Apply the trimmed payload back to the original object + payload.payload = payloadCopy.payload; }; /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 43b77950b3cc8..755d77d6b0b19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1001,6 +1001,9 @@ importers: https-proxy-agent: specifier: 'catalog:' version: 7.0.6 + ignore: + specifier: ^5.2.0 + version: 5.2.4 js-tiktoken: specifier: ^1.0.12 version: 1.0.12