mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-16 17:46:45 +00:00
refactor(Structured Output Parser Node): Support schema via expression (#16671)
This commit is contained in:
@@ -36,8 +36,6 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise<INodeE
|
|||||||
|
|
||||||
const returnData: INodeExecutionData[] = [];
|
const returnData: INodeExecutionData[] = [];
|
||||||
const items = this.getInputData();
|
const items = this.getInputData();
|
||||||
const outputParser = await getOptionalOutputParser(this);
|
|
||||||
const tools = await getTools(this, outputParser);
|
|
||||||
const batchSize = this.getNodeParameter('options.batching.batchSize', 0, 1) as number;
|
const batchSize = this.getNodeParameter('options.batching.batchSize', 0, 1) as number;
|
||||||
const delayBetweenBatches = this.getNodeParameter(
|
const delayBetweenBatches = this.getNodeParameter(
|
||||||
'options.batching.delayBetweenBatches',
|
'options.batching.delayBetweenBatches',
|
||||||
@@ -61,7 +59,8 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise<INodeE
|
|||||||
if (input === undefined) {
|
if (input === undefined) {
|
||||||
throw new NodeOperationError(this.getNode(), 'The “text” parameter is empty.');
|
throw new NodeOperationError(this.getNode(), 'The “text” parameter is empty.');
|
||||||
}
|
}
|
||||||
|
const outputParser = await getOptionalOutputParser(this, itemIndex);
|
||||||
|
const tools = await getTools(this, outputParser);
|
||||||
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
const options = this.getNodeParameter('options', itemIndex, {}) as {
|
||||||
systemMessage?: string;
|
systemMessage?: string;
|
||||||
maxIterations?: number;
|
maxIterations?: number;
|
||||||
@@ -112,7 +111,9 @@ export async function toolsAgentExecute(this: IExecuteFunctions): Promise<INodeE
|
|||||||
});
|
});
|
||||||
|
|
||||||
const batchResults = await Promise.allSettled(batchPromises);
|
const batchResults = await Promise.allSettled(batchPromises);
|
||||||
|
// This is only used to check if the output parser is connected
|
||||||
|
// so we can parse the output if needed. Actual output parsing is done in the loop above
|
||||||
|
const outputParser = await getOptionalOutputParser(this, 0);
|
||||||
batchResults.forEach((result, index) => {
|
batchResults.forEach((result, index) => {
|
||||||
const itemIndex = i + index;
|
const itemIndex = i + index;
|
||||||
if (result.status === 'rejected') {
|
if (result.status === 'rejected') {
|
||||||
|
|||||||
@@ -5,12 +5,21 @@ import type { Tool } from 'langchain/tools';
|
|||||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||||
|
|
||||||
import * as helpers from '../../../../../utils/helpers';
|
import * as helpers from '../../../../../utils/helpers';
|
||||||
|
import * as outputParserModule from '../../../../../utils/output_parsers/N8nOutputParser';
|
||||||
import { toolsAgentExecute } from '../../agents/ToolsAgent/V2/execute';
|
import { toolsAgentExecute } from '../../agents/ToolsAgent/V2/execute';
|
||||||
|
|
||||||
|
jest.mock('../../../../../utils/output_parsers/N8nOutputParser', () => ({
|
||||||
|
getOptionalOutputParser: jest.fn(),
|
||||||
|
N8nStructuredOutputParser: jest.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
const mockHelpers = mock<IExecuteFunctions['helpers']>();
|
const mockHelpers = mock<IExecuteFunctions['helpers']>();
|
||||||
const mockContext = mock<IExecuteFunctions>({ helpers: mockHelpers });
|
const mockContext = mock<IExecuteFunctions>({ helpers: mockHelpers });
|
||||||
|
|
||||||
beforeEach(() => jest.resetAllMocks());
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
jest.resetAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
describe('toolsAgentExecute', () => {
|
describe('toolsAgentExecute', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -58,8 +67,8 @@ describe('toolsAgentExecute', () => {
|
|||||||
const mockExecutor = {
|
const mockExecutor = {
|
||||||
invoke: jest
|
invoke: jest
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 1' }) })
|
.mockResolvedValueOnce({ output: { text: 'success 1' } })
|
||||||
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 2' }) }),
|
.mockResolvedValueOnce({ output: { text: 'success 2' } }),
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
||||||
@@ -108,10 +117,10 @@ describe('toolsAgentExecute', () => {
|
|||||||
const mockExecutor = {
|
const mockExecutor = {
|
||||||
invoke: jest
|
invoke: jest
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 1' }) })
|
.mockResolvedValueOnce({ output: { text: 'success 1' } })
|
||||||
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 2' }) })
|
.mockResolvedValueOnce({ output: { text: 'success 2' } })
|
||||||
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 3' }) })
|
.mockResolvedValueOnce({ output: { text: 'success 3' } })
|
||||||
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 4' }) }),
|
.mockResolvedValueOnce({ output: { text: 'success 4' } }),
|
||||||
};
|
};
|
||||||
|
|
||||||
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
||||||
@@ -163,7 +172,7 @@ describe('toolsAgentExecute', () => {
|
|||||||
const mockExecutor = {
|
const mockExecutor = {
|
||||||
invoke: jest
|
invoke: jest
|
||||||
.fn()
|
.fn()
|
||||||
.mockResolvedValueOnce({ output: '{ "text": "success" }' })
|
.mockResolvedValueOnce({ output: { text: 'success' } })
|
||||||
.mockRejectedValueOnce(new Error('Test error')),
|
.mockRejectedValueOnce(new Error('Test error')),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -220,4 +229,188 @@ describe('toolsAgentExecute', () => {
|
|||||||
|
|
||||||
await expect(toolsAgentExecute.call(mockContext)).rejects.toThrow('Test error');
|
await expect(toolsAgentExecute.call(mockContext)).rejects.toThrow('Test error');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should fetch output parser with correct item index', async () => {
|
||||||
|
const mockNode = mock<INode>();
|
||||||
|
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<BaseChatModel>();
|
||||||
|
mockModel.bindTools = jest.fn();
|
||||||
|
mockModel.lc_namespace = ['chat_models'];
|
||||||
|
mockContext.getInputConnectionData.mockResolvedValue(mockModel);
|
||||||
|
|
||||||
|
const mockTools = [mock<Tool>()];
|
||||||
|
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue(mockTools);
|
||||||
|
|
||||||
|
const mockParser1 = mock<outputParserModule.N8nStructuredOutputParser>();
|
||||||
|
const mockParser2 = mock<outputParserModule.N8nStructuredOutputParser>();
|
||||||
|
const mockParser3 = mock<outputParserModule.N8nStructuredOutputParser>();
|
||||||
|
|
||||||
|
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<INode>();
|
||||||
|
mockNode.typeVersion = 2;
|
||||||
|
mockContext.getNode.mockReturnValue(mockNode);
|
||||||
|
mockContext.getInputData.mockReturnValue([
|
||||||
|
{ json: { text: 'test input 1' } },
|
||||||
|
{ json: { text: 'test input 2' } },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const mockModel = mock<BaseChatModel>();
|
||||||
|
mockModel.bindTools = jest.fn();
|
||||||
|
mockModel.lc_namespace = ['chat_models'];
|
||||||
|
mockContext.getInputConnectionData.mockResolvedValue(mockModel);
|
||||||
|
|
||||||
|
const mockParser1 = mock<outputParserModule.N8nStructuredOutputParser>();
|
||||||
|
const mockParser2 = mock<outputParserModule.N8nStructuredOutputParser>();
|
||||||
|
|
||||||
|
jest
|
||||||
|
.spyOn(outputParserModule, 'getOptionalOutputParser')
|
||||||
|
.mockResolvedValueOnce(mockParser1)
|
||||||
|
.mockResolvedValueOnce(mockParser2);
|
||||||
|
|
||||||
|
const getToolsSpy = jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
|
||||||
|
|
||||||
|
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<INode>();
|
||||||
|
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<BaseChatModel>();
|
||||||
|
mockModel.bindTools = jest.fn();
|
||||||
|
mockModel.lc_namespace = ['chat_models'];
|
||||||
|
mockContext.getInputConnectionData.mockResolvedValue(mockModel);
|
||||||
|
|
||||||
|
const mockParsers = [
|
||||||
|
mock<outputParserModule.N8nStructuredOutputParser>(),
|
||||||
|
mock<outputParserModule.N8nStructuredOutputParser>(),
|
||||||
|
mock<outputParserModule.N8nStructuredOutputParser>(),
|
||||||
|
mock<outputParserModule.N8nStructuredOutputParser>(),
|
||||||
|
];
|
||||||
|
|
||||||
|
const getOptionalOutputParserSpy = jest
|
||||||
|
.spyOn(outputParserModule, 'getOptionalOutputParser')
|
||||||
|
.mockImplementation(async (_ctx, index) => mockParsers[index || 0]);
|
||||||
|
|
||||||
|
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export const processItem = async (ctx: IExecuteFunctions, itemIndex: number) =>
|
|||||||
)) as BaseLanguageModel;
|
)) as BaseLanguageModel;
|
||||||
|
|
||||||
// Get output parser if configured
|
// Get output parser if configured
|
||||||
const outputParser = await getOptionalOutputParser(ctx);
|
const outputParser = await getOptionalOutputParser(ctx, itemIndex);
|
||||||
|
|
||||||
// Get user prompt based on node version
|
// Get user prompt based on node version
|
||||||
let prompt: string;
|
let prompt: string;
|
||||||
|
|||||||
@@ -66,6 +66,10 @@ describe('ChainLlm Node', () => {
|
|||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
describe('description', () => {
|
describe('description', () => {
|
||||||
it('should have the expected properties', () => {
|
it('should have the expected properties', () => {
|
||||||
expect(node.description).toBeDefined();
|
expect(node.description).toBeDefined();
|
||||||
@@ -500,5 +504,237 @@ describe('ChainLlm Node', () => {
|
|||||||
|
|
||||||
expect(responseFormatterModule.formatResponse).toHaveBeenCalledWith(markdownResponse, true);
|
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<outputParserModule.N8nOutputParser>();
|
||||||
|
const mockParser2 = mock<outputParserModule.N8nOutputParser>();
|
||||||
|
const mockParser3 = mock<outputParserModule.N8nOutputParser>();
|
||||||
|
|
||||||
|
// 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<outputParserModule.N8nOutputParser>());
|
||||||
|
|
||||||
|
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<outputParserModule.N8nOutputParser>(),
|
||||||
|
undefined,
|
||||||
|
mock<outputParserModule.N8nOutputParser>(),
|
||||||
|
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],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
120
packages/@n8n/nodes-langchain/utils/descriptions.test.ts
Normal file
120
packages/@n8n/nodes-langchain/utils/descriptions.test.ts
Normal file
@@ -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'],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -84,7 +84,7 @@ export const buildInputSchemaField = (props?: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}`,
|
}`,
|
||||||
noDataExpression: true,
|
noDataExpression: false,
|
||||||
typeOptions: {
|
typeOptions: {
|
||||||
rows: 10,
|
rows: 10,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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<IExecuteFunctions>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockContext = mock<IExecuteFunctions>();
|
||||||
|
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<N8nStructuredOutputParser>();
|
||||||
|
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<N8nStructuredOutputParser>();
|
||||||
|
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<N8nStructuredOutputParser>();
|
||||||
|
const mockParser2 = mock<N8nStructuredOutputParser>();
|
||||||
|
const mockParser3 = mock<N8nStructuredOutputParser>();
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -14,13 +14,14 @@ export { N8nOutputFixingParser, N8nItemListOutputParser, N8nStructuredOutputPars
|
|||||||
|
|
||||||
export async function getOptionalOutputParser(
|
export async function getOptionalOutputParser(
|
||||||
ctx: IExecuteFunctions,
|
ctx: IExecuteFunctions,
|
||||||
|
index: number = 0,
|
||||||
): Promise<N8nOutputParser | undefined> {
|
): Promise<N8nOutputParser | undefined> {
|
||||||
let outputParser: N8nOutputParser | undefined;
|
let outputParser: N8nOutputParser | undefined;
|
||||||
|
|
||||||
if (ctx.getNodeParameter('hasOutputParser', 0, true) === true) {
|
if (ctx.getNodeParameter('hasOutputParser', 0, true) === true) {
|
||||||
outputParser = (await ctx.getInputConnectionData(
|
outputParser = (await ctx.getInputConnectionData(
|
||||||
NodeConnectionTypes.AiOutputParser,
|
NodeConnectionTypes.AiOutputParser,
|
||||||
0,
|
index,
|
||||||
)) as N8nOutputParser;
|
)) as N8nOutputParser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user