diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V2/execute.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V2/execute.ts index b0fe988d7d..36d26f7a50 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V2/execute.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/agents/ToolsAgent/V2/execute.ts @@ -36,8 +36,6 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise { const itemIndex = i + index; if (result.status === 'rejected') { diff --git a/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/ToolsAgent/ToolsAgentV2.test.ts b/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/ToolsAgent/ToolsAgentV2.test.ts index de8c0521e7..5dbbd2d939 100644 --- a/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/ToolsAgent/ToolsAgentV2.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/agents/Agent/test/ToolsAgent/ToolsAgentV2.test.ts @@ -5,12 +5,21 @@ import type { Tool } from 'langchain/tools'; import type { IExecuteFunctions, INode } from 'n8n-workflow'; import * as helpers from '../../../../../utils/helpers'; +import * as outputParserModule from '../../../../../utils/output_parsers/N8nOutputParser'; import { toolsAgentExecute } from '../../agents/ToolsAgent/V2/execute'; +jest.mock('../../../../../utils/output_parsers/N8nOutputParser', () => ({ + getOptionalOutputParser: jest.fn(), + N8nStructuredOutputParser: jest.fn(), +})); + const mockHelpers = mock(); const mockContext = mock({ helpers: mockHelpers }); -beforeEach(() => jest.resetAllMocks()); +beforeEach(() => { + jest.clearAllMocks(); + jest.resetAllMocks(); +}); describe('toolsAgentExecute', () => { beforeEach(() => { @@ -58,8 +67,8 @@ describe('toolsAgentExecute', () => { const mockExecutor = { invoke: jest .fn() - .mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 1' }) }) - .mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 2' }) }), + .mockResolvedValueOnce({ output: { text: 'success 1' } }) + .mockResolvedValueOnce({ output: { text: 'success 2' } }), }; jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any); @@ -108,10 +117,10 @@ describe('toolsAgentExecute', () => { const mockExecutor = { invoke: jest .fn() - .mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 1' }) }) - .mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 2' }) }) - .mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 3' }) }) - .mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 4' }) }), + .mockResolvedValueOnce({ output: { text: 'success 1' } }) + .mockResolvedValueOnce({ output: { text: 'success 2' } }) + .mockResolvedValueOnce({ output: { text: 'success 3' } }) + .mockResolvedValueOnce({ output: { text: 'success 4' } }), }; jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any); @@ -163,7 +172,7 @@ describe('toolsAgentExecute', () => { const mockExecutor = { invoke: jest .fn() - .mockResolvedValueOnce({ output: '{ "text": "success" }' }) + .mockResolvedValueOnce({ output: { text: 'success' } }) .mockRejectedValueOnce(new Error('Test error')), }; @@ -220,4 +229,188 @@ describe('toolsAgentExecute', () => { await expect(toolsAgentExecute.call(mockContext)).rejects.toThrow('Test error'); }); + + it('should fetch output parser with correct item index', async () => { + const mockNode = mock(); + mockNode.typeVersion = 2; + mockContext.getNode.mockReturnValue(mockNode); + mockContext.getInputData.mockReturnValue([ + { json: { text: 'test input 1' } }, + { json: { text: 'test input 2' } }, + { json: { text: 'test input 3' } }, + ]); + + const mockModel = mock(); + mockModel.bindTools = jest.fn(); + mockModel.lc_namespace = ['chat_models']; + mockContext.getInputConnectionData.mockResolvedValue(mockModel); + + const mockTools = [mock()]; + jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue(mockTools); + + const mockParser1 = mock(); + const mockParser2 = mock(); + const mockParser3 = mock(); + + const getOptionalOutputParserSpy = jest + .spyOn(outputParserModule, 'getOptionalOutputParser') + .mockResolvedValueOnce(mockParser1) + .mockResolvedValueOnce(mockParser2) + .mockResolvedValueOnce(mockParser3) + .mockResolvedValueOnce(undefined); // For the check call + + mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => { + if (param === 'text') return 'test input'; + if (param === 'options.batching.batchSize') return defaultValue; + if (param === 'options.batching.delayBetweenBatches') return defaultValue; + if (param === 'options') + return { + systemMessage: 'You are a helpful assistant', + maxIterations: 10, + returnIntermediateSteps: false, + passthroughBinaryImages: true, + }; + return defaultValue; + }); + + const mockExecutor = { + invoke: jest + .fn() + .mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 1' }) }) + .mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 2' }) }) + .mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 3' }) }), + }; + + jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any); + + await toolsAgentExecute.call(mockContext); + + // Verify getOptionalOutputParser was called with correct indices + expect(getOptionalOutputParserSpy).toHaveBeenCalledTimes(6); + expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(1, mockContext, 0); + expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(2, mockContext, 0); + expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(3, mockContext, 1); + expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(4, mockContext, 0); + expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(5, mockContext, 2); + }); + + it('should pass different output parsers to getTools for each item', async () => { + const mockNode = mock(); + mockNode.typeVersion = 2; + mockContext.getNode.mockReturnValue(mockNode); + mockContext.getInputData.mockReturnValue([ + { json: { text: 'test input 1' } }, + { json: { text: 'test input 2' } }, + ]); + + const mockModel = mock(); + mockModel.bindTools = jest.fn(); + mockModel.lc_namespace = ['chat_models']; + mockContext.getInputConnectionData.mockResolvedValue(mockModel); + + const mockParser1 = mock(); + const mockParser2 = mock(); + + jest + .spyOn(outputParserModule, 'getOptionalOutputParser') + .mockResolvedValueOnce(mockParser1) + .mockResolvedValueOnce(mockParser2); + + const getToolsSpy = jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock()]); + + mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => { + if (param === 'text') return 'test input'; + if (param === 'options') + return { + systemMessage: 'You are a helpful assistant', + maxIterations: 10, + returnIntermediateSteps: false, + passthroughBinaryImages: true, + }; + return defaultValue; + }); + + const mockExecutor = { + invoke: jest + .fn() + .mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 1' }) }) + .mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 2' }) }), + }; + + jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any); + + await toolsAgentExecute.call(mockContext); + + // Verify getTools was called with different parsers + expect(getToolsSpy).toHaveBeenCalledTimes(2); + expect(getToolsSpy).toHaveBeenNthCalledWith(1, mockContext, true, false); + expect(getToolsSpy).toHaveBeenNthCalledWith(2, mockContext, true, false); + }); + + it('should maintain correct parser-item mapping in batch processing', async () => { + const mockNode = mock(); + mockNode.typeVersion = 2; + mockContext.getNode.mockReturnValue(mockNode); + mockContext.getInputData.mockReturnValue([ + { json: { text: 'test input 1' } }, + { json: { text: 'test input 2' } }, + { json: { text: 'test input 3' } }, + { json: { text: 'test input 4' } }, + ]); + + const mockModel = mock(); + mockModel.bindTools = jest.fn(); + mockModel.lc_namespace = ['chat_models']; + mockContext.getInputConnectionData.mockResolvedValue(mockModel); + + const mockParsers = [ + mock(), + mock(), + mock(), + mock(), + ]; + + const getOptionalOutputParserSpy = jest + .spyOn(outputParserModule, 'getOptionalOutputParser') + .mockImplementation(async (_ctx, index) => mockParsers[index || 0]); + + jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock()]); + + mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => { + if (param === 'options.batching.batchSize') return 2; + if (param === 'options.batching.delayBetweenBatches') return 0; + if (param === 'text') return 'test input'; + if (param === 'options') + return { + systemMessage: 'You are a helpful assistant', + maxIterations: 10, + returnIntermediateSteps: false, + passthroughBinaryImages: true, + }; + return defaultValue; + }); + + const mockExecutor = { + invoke: jest + .fn() + .mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 1' }) }) + .mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 2' }) }) + .mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 3' }) }) + .mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 4' }) }), + }; + + jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any); + + await toolsAgentExecute.call(mockContext); + + // Verify each item got its corresponding parser based on index + // It's called once per item + once to check if output parser is connected + expect(getOptionalOutputParserSpy).toHaveBeenCalledTimes(6); + expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(1, mockContext, 0); + expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(2, mockContext, 1); + expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(3, mockContext, 0); + expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(4, mockContext, 2); + expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(5, mockContext, 3); + expect(getOptionalOutputParserSpy).toHaveBeenNthCalledWith(6, mockContext, 0); + }); }); diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/methods/processItem.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/methods/processItem.ts index bde406edb5..fdd284085c 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/methods/processItem.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/methods/processItem.ts @@ -14,7 +14,7 @@ export const processItem = async (ctx: IExecuteFunctions, itemIndex: number) => )) as BaseLanguageModel; // Get output parser if configured - const outputParser = await getOptionalOutputParser(ctx); + const outputParser = await getOptionalOutputParser(ctx, itemIndex); // Get user prompt based on node version let prompt: string; diff --git a/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/test/ChainLlm.node.test.ts b/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/test/ChainLlm.node.test.ts index 9069e47611..aef85db4a1 100644 --- a/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/test/ChainLlm.node.test.ts +++ b/packages/@n8n/nodes-langchain/nodes/chains/ChainLLM/test/ChainLlm.node.test.ts @@ -66,6 +66,10 @@ describe('ChainLlm Node', () => { jest.clearAllMocks(); }); + afterEach(() => { + jest.clearAllMocks(); + }); + describe('description', () => { it('should have the expected properties', () => { expect(node.description).toBeDefined(); @@ -500,5 +504,237 @@ describe('ChainLlm Node', () => { expect(responseFormatterModule.formatResponse).toHaveBeenCalledWith(markdownResponse, true); }); + + it('should pass correct itemIndex to getOptionalOutputParser', async () => { + // Clear any previous calls to the mock + (outputParserModule.getOptionalOutputParser as jest.Mock).mockClear(); + + mockExecuteFunction.getInputData.mockReturnValue([ + { json: { item: 1 } }, + { json: { item: 2 } }, + { json: { item: 3 } }, + ]); + + (helperModule.getPromptInputByType as jest.Mock) + .mockReturnValueOnce('Test prompt 1') + .mockReturnValueOnce('Test prompt 2') + .mockReturnValueOnce('Test prompt 3'); + + const mockParser1 = mock(); + const mockParser2 = mock(); + const mockParser3 = mock(); + + // Use the already mocked function instead of creating a spy + // First call is for the initial check in execute(), then one per item + (outputParserModule.getOptionalOutputParser as jest.Mock) + .mockResolvedValueOnce(undefined) // Initial call in execute() + .mockResolvedValueOnce(mockParser1) + .mockResolvedValueOnce(mockParser2) + .mockResolvedValueOnce(mockParser3); + + (executeChainModule.executeChain as jest.Mock) + .mockResolvedValueOnce(['Response 1']) + .mockResolvedValueOnce(['Response 2']) + .mockResolvedValueOnce(['Response 3']); + + await node.execute.call(mockExecuteFunction); + + // Verify getOptionalOutputParser was called with correct indices + // First call without index, then 3 calls with indices + expect(outputParserModule.getOptionalOutputParser).toHaveBeenCalledTimes(4); + expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith( + 1, + mockExecuteFunction, + ); // Initial call + expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith( + 2, + mockExecuteFunction, + 0, + ); + expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith( + 3, + mockExecuteFunction, + 1, + ); + expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith( + 4, + mockExecuteFunction, + 2, + ); + + // Verify executeChain was called with the corresponding parsers + expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(1, { + context: mockExecuteFunction, + itemIndex: 0, + query: 'Test prompt 1', + llm: expect.any(Object), + outputParser: mockParser1, + messages: [], + }); + expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(2, { + context: mockExecuteFunction, + itemIndex: 1, + query: 'Test prompt 2', + llm: expect.any(Object), + outputParser: mockParser2, + messages: [], + }); + expect(executeChainModule.executeChain).toHaveBeenNthCalledWith(3, { + context: mockExecuteFunction, + itemIndex: 2, + query: 'Test prompt 3', + llm: expect.any(Object), + outputParser: mockParser3, + messages: [], + }); + }); + + it('should handle different output parsers for each item', async () => { + mockExecuteFunction.getNode.mockReturnValue({ + name: 'Chain LLM', + typeVersion: 1.6, + parameters: {}, + } as INode); + + mockExecuteFunction.getInputData.mockReturnValue([ + { json: { item: 1 } }, + { json: { item: 2 } }, + ]); + + (helperModule.getPromptInputByType as jest.Mock) + .mockReturnValueOnce('Test prompt 1') + .mockReturnValueOnce('Test prompt 2'); + + // First item has no parser, second has a parser + (outputParserModule.getOptionalOutputParser as jest.Mock) + .mockResolvedValueOnce(undefined) + .mockResolvedValueOnce(mock()); + + const response1 = { text: 'plain response' }; + const response2 = { structured: 'response' }; + + (executeChainModule.executeChain as jest.Mock) + .mockResolvedValueOnce([response1]) + .mockResolvedValueOnce([response2]); + + const formatResponseSpy = jest.spyOn(responseFormatterModule, 'formatResponse'); + + await node.execute.call(mockExecuteFunction); + + // First item without parser should not unwrap objects (even in v1.6) + // Actually, let me check the logic again... v1.6 unwraps by default + // but having an output parser always triggers unwrapping + expect(formatResponseSpy).toHaveBeenNthCalledWith(1, response1, true); // v1.6 unwraps + expect(formatResponseSpy).toHaveBeenNthCalledWith(2, response2, true); // has parser, unwraps + }); + + it('should maintain parser consistency across batch processing', async () => { + // Clear any previous calls to the mock + (outputParserModule.getOptionalOutputParser as jest.Mock).mockClear(); + + mockExecuteFunction.getNode.mockReturnValue({ + name: 'Chain LLM', + typeVersion: 1.7, + parameters: {}, + } as INode); + + mockExecuteFunction.getInputData.mockReturnValue([ + { json: { item: 1 } }, + { json: { item: 2 } }, + { json: { item: 3 } }, + { json: { item: 4 } }, + ]); + + mockExecuteFunction.getNodeParameter.mockImplementation((param, _itemIndex, defaultValue) => { + if (param === 'batching.batchSize') return 2; + if (param === 'batching.delayBetweenBatches') return 0; + if (param === 'messages.messageValues') return []; + return defaultValue; + }); + + (helperModule.getPromptInputByType as jest.Mock) + .mockReturnValueOnce('Test prompt 1') + .mockReturnValueOnce('Test prompt 2') + .mockReturnValueOnce('Test prompt 3') + .mockReturnValueOnce('Test prompt 4'); + + const mockParsers = [ + mock(), + undefined, + mock(), + undefined, + ]; + + // Use the already mocked function instead of creating a spy + // Account for initial call without index + (outputParserModule.getOptionalOutputParser as jest.Mock).mockImplementation( + async (_ctx, index) => { + if (index === undefined) return undefined; // Initial call + return mockParsers[index]; + }, + ); + + (executeChainModule.executeChain as jest.Mock) + .mockResolvedValueOnce(['Response 1']) + .mockResolvedValueOnce(['Response 2']) + .mockResolvedValueOnce(['Response 3']) + .mockResolvedValueOnce(['Response 4']); + + await node.execute.call(mockExecuteFunction); + + // Verify each item was processed with correct index + // First call without index, then 4 calls with indices + expect(outputParserModule.getOptionalOutputParser).toHaveBeenCalledTimes(5); + expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith( + 1, + mockExecuteFunction, + ); // Initial call + expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith( + 2, + mockExecuteFunction, + 0, + ); + expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith( + 3, + mockExecuteFunction, + 1, + ); + expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith( + 4, + mockExecuteFunction, + 2, + ); + expect(outputParserModule.getOptionalOutputParser).toHaveBeenNthCalledWith( + 5, + mockExecuteFunction, + 3, + ); + + // Verify executeChain received correct parsers + expect(executeChainModule.executeChain).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ + outputParser: mockParsers[0], + }), + ); + expect(executeChainModule.executeChain).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + outputParser: mockParsers[1], + }), + ); + expect(executeChainModule.executeChain).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ + outputParser: mockParsers[2], + }), + ); + expect(executeChainModule.executeChain).toHaveBeenNthCalledWith( + 4, + expect.objectContaining({ + outputParser: mockParsers[3], + }), + ); + }); }); }); diff --git a/packages/@n8n/nodes-langchain/utils/descriptions.test.ts b/packages/@n8n/nodes-langchain/utils/descriptions.test.ts new file mode 100644 index 0000000000..94954346a9 --- /dev/null +++ b/packages/@n8n/nodes-langchain/utils/descriptions.test.ts @@ -0,0 +1,120 @@ +import { buildInputSchemaField } from './descriptions'; + +describe('buildInputSchemaField', () => { + it('should create input schema field with noDataExpression set to false', () => { + const result = buildInputSchemaField(); + + expect(result.noDataExpression).toBe(false); + expect(result.displayName).toBe('Input Schema'); + expect(result.name).toBe('inputSchema'); + expect(result.type).toBe('json'); + }); + + it('should include typeOptions with rows set to 10', () => { + const result = buildInputSchemaField(); + + expect(result.typeOptions).toEqual({ rows: 10 }); + }); + + it('should have correct default JSON schema', () => { + const result = buildInputSchemaField(); + + const expectedDefault = `{ +"type": "object", +"properties": { + "some_input": { + "type": "string", + "description": "Some input to the function" + } + } +}`; + expect(result.default).toBe(expectedDefault); + }); + + it('should include display options with schemaType manual', () => { + const result = buildInputSchemaField(); + + expect(result.displayOptions).toEqual({ + show: { + schemaType: ['manual'], + }, + }); + }); + + it('should merge showExtraProps when provided', () => { + const result = buildInputSchemaField({ + showExtraProps: { + mode: ['advanced'], + authentication: ['oauth2'], + }, + }); + + expect(result.displayOptions).toEqual({ + show: { + mode: ['advanced'], + authentication: ['oauth2'], + schemaType: ['manual'], + }, + }); + }); + + it('should include description and hint', () => { + const result = buildInputSchemaField(); + + expect(result.description).toBe('Schema to use for the function'); + expect(result.hint).toContain('JSON Schema'); + expect(result.hint).toContain('json-schema.org'); + }); + + it('should allow data expressions in the schema field', () => { + const result = buildInputSchemaField(); + + // noDataExpression is false, which means expressions are allowed + expect(result.noDataExpression).toBe(false); + + // Since noDataExpression is false, this should be valid + expect(typeof result.default).toBe('string'); + expect(result.noDataExpression).toBe(false); + }); + + it('should be a valid INodeProperties object', () => { + const result = buildInputSchemaField(); + + // Check all required fields for INodeProperties + expect(result).toHaveProperty('displayName'); + expect(result).toHaveProperty('name'); + expect(result).toHaveProperty('type'); + expect(result).toHaveProperty('default'); + + // Verify types + expect(typeof result.displayName).toBe('string'); + expect(typeof result.name).toBe('string'); + expect(typeof result.type).toBe('string'); + expect(typeof result.default).toBe('string'); + }); + + it('should properly handle edge cases with showExtraProps', () => { + // Empty showExtraProps + const result1 = buildInputSchemaField({ showExtraProps: {} }); + expect(result1.displayOptions).toEqual({ + show: { + schemaType: ['manual'], + }, + }); + + // showExtraProps with undefined values + const result2 = buildInputSchemaField({ + showExtraProps: { + field1: undefined, + field2: ['value2'], + }, + }); + expect(result2.displayOptions).toEqual({ + show: { + field1: undefined, + field2: ['value2'], + schemaType: ['manual'], + }, + }); + }); +}); diff --git a/packages/@n8n/nodes-langchain/utils/descriptions.ts b/packages/@n8n/nodes-langchain/utils/descriptions.ts index 708b974858..1c429e9533 100644 --- a/packages/@n8n/nodes-langchain/utils/descriptions.ts +++ b/packages/@n8n/nodes-langchain/utils/descriptions.ts @@ -84,7 +84,7 @@ export const buildInputSchemaField = (props?: { } } }`, - noDataExpression: true, + noDataExpression: false, typeOptions: { rows: 10, }, diff --git a/packages/@n8n/nodes-langchain/utils/output_parsers/N8nOutputParser.test.ts b/packages/@n8n/nodes-langchain/utils/output_parsers/N8nOutputParser.test.ts new file mode 100644 index 0000000000..38dba20591 --- /dev/null +++ b/packages/@n8n/nodes-langchain/utils/output_parsers/N8nOutputParser.test.ts @@ -0,0 +1,101 @@ +import { mock } from 'jest-mock-extended'; +import type { IExecuteFunctions } from 'n8n-workflow'; +import { NodeConnectionTypes } from 'n8n-workflow'; + +import { getOptionalOutputParser } from './N8nOutputParser'; +import type { N8nStructuredOutputParser } from './N8nStructuredOutputParser'; + +describe('getOptionalOutputParser', () => { + let mockContext: jest.Mocked; + + beforeEach(() => { + mockContext = mock(); + jest.clearAllMocks(); + }); + + it('should return undefined when hasOutputParser is false', async () => { + mockContext.getNodeParameter.mockReturnValue(false); + + const result = await getOptionalOutputParser(mockContext); + + expect(result).toBeUndefined(); + expect(mockContext.getNodeParameter).toHaveBeenCalledWith('hasOutputParser', 0, true); + expect(mockContext.getInputConnectionData).not.toHaveBeenCalled(); + }); + + it('should return output parser when hasOutputParser is true with default index', async () => { + const mockParser = mock(); + mockContext.getNodeParameter.mockReturnValue(true); + mockContext.getInputConnectionData.mockResolvedValue(mockParser); + + const result = await getOptionalOutputParser(mockContext); + + expect(result).toBe(mockParser); + expect(mockContext.getNodeParameter).toHaveBeenCalledWith('hasOutputParser', 0, true); + expect(mockContext.getInputConnectionData).toHaveBeenCalledWith( + NodeConnectionTypes.AiOutputParser, + 0, + ); + }); + + it('should use provided index when fetching output parser', async () => { + const mockParser = mock(); + mockContext.getNodeParameter.mockReturnValue(true); + mockContext.getInputConnectionData.mockResolvedValue(mockParser); + + const result = await getOptionalOutputParser(mockContext, 2); + + expect(result).toBe(mockParser); + expect(mockContext.getNodeParameter).toHaveBeenCalledWith('hasOutputParser', 0, true); + expect(mockContext.getInputConnectionData).toHaveBeenCalledWith( + NodeConnectionTypes.AiOutputParser, + 2, + ); + }); + + it('should handle different index values correctly', async () => { + const mockParser1 = mock(); + const mockParser2 = mock(); + const mockParser3 = mock(); + + mockContext.getNodeParameter.mockReturnValue(true); + mockContext.getInputConnectionData + .mockResolvedValueOnce(mockParser1) + .mockResolvedValueOnce(mockParser2) + .mockResolvedValueOnce(mockParser3); + + const result1 = await getOptionalOutputParser(mockContext, 0); + const result2 = await getOptionalOutputParser(mockContext, 1); + const result3 = await getOptionalOutputParser(mockContext, 5); + + expect(result1).toBe(mockParser1); + expect(result2).toBe(mockParser2); + expect(result3).toBe(mockParser3); + + expect(mockContext.getInputConnectionData).toHaveBeenNthCalledWith( + 1, + NodeConnectionTypes.AiOutputParser, + 0, + ); + expect(mockContext.getInputConnectionData).toHaveBeenNthCalledWith( + 2, + NodeConnectionTypes.AiOutputParser, + 1, + ); + expect(mockContext.getInputConnectionData).toHaveBeenNthCalledWith( + 3, + NodeConnectionTypes.AiOutputParser, + 5, + ); + }); + + it('should always check hasOutputParser at index 0', async () => { + mockContext.getNodeParameter.mockReturnValue(false); + + await getOptionalOutputParser(mockContext, 3); + + // Even when called with index 3, hasOutputParser is checked at index 0 + expect(mockContext.getNodeParameter).toHaveBeenCalledWith('hasOutputParser', 0, true); + expect(mockContext.getInputConnectionData).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/@n8n/nodes-langchain/utils/output_parsers/N8nOutputParser.ts b/packages/@n8n/nodes-langchain/utils/output_parsers/N8nOutputParser.ts index 51bd79591e..633ce19386 100644 --- a/packages/@n8n/nodes-langchain/utils/output_parsers/N8nOutputParser.ts +++ b/packages/@n8n/nodes-langchain/utils/output_parsers/N8nOutputParser.ts @@ -14,13 +14,14 @@ export { N8nOutputFixingParser, N8nItemListOutputParser, N8nStructuredOutputPars export async function getOptionalOutputParser( ctx: IExecuteFunctions, + index: number = 0, ): Promise { let outputParser: N8nOutputParser | undefined; if (ctx.getNodeParameter('hasOutputParser', 0, true) === true) { outputParser = (await ctx.getInputConnectionData( NodeConnectionTypes.AiOutputParser, - 0, + index, )) as N8nOutputParser; }