first commit
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
Security: Sync from Public / sync-from-public (push) Has been cancelled
Test: Benchmark Nightly / build (push) Has been cancelled
Test: Benchmark Nightly / Notify Cats on failure (push) Has been cancelled
CI: Python / Checks (push) Has been cancelled
Test: Evals Python / Workflow Comparison Python (push) Has been cancelled
Util: Check Docs URLs / check-docs-urls (push) Has been cancelled
Test: Visual Storybook / Cloudflare Pages (push) Has been cancelled
Test: E2E Performance / build-and-test-performance (push) Has been cancelled
Test: Workflows Nightly / Run Workflow Tests (push) Has been cancelled
Util: Cleanup CI Docker Images / Delete stale CI images (push) Has been cancelled
Test: Benchmark Destroy Env / build (push) Has been cancelled
Util: Update Node Popularity / update-popularity (push) Has been cancelled
Test: E2E Coverage Weekly / Coverage Tests (push) Has been cancelled
This commit is contained in:
+159
@@ -0,0 +1,159 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { AgentExecutor } from '@langchain/classic/agents';
|
||||
import type { Tool } from '@langchain/classic/tools';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
|
||||
import * as helpers from '../../../../../utils/helpers';
|
||||
import { toolsAgentExecute } from '../../agents/ToolsAgent/V1/execute';
|
||||
|
||||
const mockHelpers = mock<IExecuteFunctions['helpers']>();
|
||||
const mockContext = mock<IExecuteFunctions>({ helpers: mockHelpers });
|
||||
|
||||
beforeEach(() => jest.resetAllMocks());
|
||||
|
||||
describe('toolsAgentExecute', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockContext.logger = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should process items', async () => {
|
||||
const mockNode = mock<INode>();
|
||||
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 mockTools = [mock<Tool>()];
|
||||
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue(mockTools);
|
||||
|
||||
// Mock getNodeParameter to return default values
|
||||
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);
|
||||
|
||||
const result = await toolsAgentExecute.call(mockContext);
|
||||
|
||||
expect(mockExecutor.invoke).toHaveBeenCalledTimes(2);
|
||||
expect(result[0]).toHaveLength(2);
|
||||
expect(result[0][0].json).toEqual({ output: { text: 'success 1' } });
|
||||
expect(result[0][1].json).toEqual({ output: { text: 'success 2' } });
|
||||
});
|
||||
|
||||
it('should handle errors when continueOnFail is true', async () => {
|
||||
const mockNode = mock<INode>();
|
||||
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 mockTools = [mock<Tool>()];
|
||||
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue(mockTools);
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
mockContext.continueOnFail.mockReturnValue(true);
|
||||
|
||||
const mockExecutor = {
|
||||
invoke: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ output: '{ "text": "success" }' })
|
||||
.mockRejectedValueOnce(new Error('Test error')),
|
||||
};
|
||||
|
||||
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
||||
|
||||
const result = await toolsAgentExecute.call(mockContext);
|
||||
|
||||
expect(result[0]).toHaveLength(2);
|
||||
expect(result[0][0].json).toEqual({ output: { text: 'success' } });
|
||||
expect(result[0][1].json).toEqual({ error: 'Test error' });
|
||||
});
|
||||
|
||||
it('should throw error in when continueOnFail is false', async () => {
|
||||
const mockNode = mock<INode>();
|
||||
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 mockTools = [mock<Tool>()];
|
||||
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue(mockTools);
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
mockContext.continueOnFail.mockReturnValue(false);
|
||||
|
||||
const mockExecutor = {
|
||||
invoke: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success' }) })
|
||||
.mockRejectedValueOnce(new Error('Test error')),
|
||||
};
|
||||
|
||||
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
||||
|
||||
await expect(toolsAgentExecute.call(mockContext)).rejects.toThrow('Test error');
|
||||
});
|
||||
});
|
||||
+910
@@ -0,0 +1,910 @@
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import { AgentExecutor } from '@langchain/classic/agents';
|
||||
import type { Tool } from '@langchain/classic/tools';
|
||||
import type { ISupplyDataFunctions, IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
|
||||
import * as helpers from '../../../../../utils/helpers';
|
||||
import * as outputParserModule from '../../../../../utils/output_parsers/N8nOutputParser';
|
||||
import * as commonModule from '../../agents/ToolsAgent/common';
|
||||
import { toolsAgentExecute } from '../../agents/ToolsAgent/V2/execute';
|
||||
|
||||
jest.mock('../../../../../utils/output_parsers/N8nOutputParser', () => ({
|
||||
getOptionalOutputParser: jest.fn(),
|
||||
N8nStructuredOutputParser: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../agents/ToolsAgent/common', () => ({
|
||||
...jest.requireActual('../../agents/ToolsAgent/common'),
|
||||
getOptionalMemory: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockHelpers = mock<IExecuteFunctions['helpers']>();
|
||||
const mockContext = mock<IExecuteFunctions>({ helpers: mockHelpers });
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.resetAllMocks();
|
||||
});
|
||||
|
||||
describe('toolsAgentExecute', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockContext.logger = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('should process items sequentially when batchSize is not set', 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 mockTools = [mock<Tool>()];
|
||||
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue(mockTools);
|
||||
|
||||
// Mock getNodeParameter to return default values
|
||||
mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
|
||||
if (param === 'text') return 'test input';
|
||||
if (param === 'needsFallback') return false;
|
||||
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: { text: 'success 1' } })
|
||||
.mockResolvedValueOnce({ output: { text: 'success 2' } }),
|
||||
};
|
||||
|
||||
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
||||
|
||||
const result = await toolsAgentExecute.call(mockContext);
|
||||
|
||||
expect(mockExecutor.invoke).toHaveBeenCalledTimes(2);
|
||||
expect(result[0]).toHaveLength(2);
|
||||
expect(result[0][0].json).toEqual({ output: { text: 'success 1' } });
|
||||
expect(result[0][1].json).toEqual({ output: { text: 'success 2' } });
|
||||
});
|
||||
|
||||
it('should process items in parallel within batches when batchSize > 1', 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 mockTools = [mock<Tool>()];
|
||||
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue(mockTools);
|
||||
|
||||
mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
|
||||
if (param === 'options.batching.batchSize') return 2;
|
||||
if (param === 'options.batching.delayBetweenBatches') return 100;
|
||||
if (param === 'text') return 'test input';
|
||||
if (param === 'needsFallback') return false;
|
||||
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: { 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);
|
||||
|
||||
const result = await toolsAgentExecute.call(mockContext);
|
||||
|
||||
expect(mockExecutor.invoke).toHaveBeenCalledTimes(4); // Each item is processed individually
|
||||
expect(result[0]).toHaveLength(4);
|
||||
|
||||
expect(result[0][0].json).toEqual({ output: { text: 'success 1' } });
|
||||
expect(result[0][1].json).toEqual({ output: { text: 'success 2' } });
|
||||
expect(result[0][2].json).toEqual({ output: { text: 'success 3' } });
|
||||
expect(result[0][3].json).toEqual({ output: { text: 'success 4' } });
|
||||
});
|
||||
|
||||
it('should handle errors in batch processing when continueOnFail is true', 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 mockTools = [mock<Tool>()];
|
||||
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue(mockTools);
|
||||
|
||||
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 === 'needsFallback') return false;
|
||||
if (param === 'options')
|
||||
return {
|
||||
systemMessage: 'You are a helpful assistant',
|
||||
maxIterations: 10,
|
||||
returnIntermediateSteps: false,
|
||||
passthroughBinaryImages: true,
|
||||
};
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
mockContext.continueOnFail.mockReturnValue(true);
|
||||
|
||||
const mockExecutor = {
|
||||
invoke: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ output: { text: 'success' } })
|
||||
.mockRejectedValueOnce(new Error('Test error')),
|
||||
};
|
||||
|
||||
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
||||
|
||||
const result = await toolsAgentExecute.call(mockContext);
|
||||
|
||||
expect(result[0]).toHaveLength(2);
|
||||
expect(result[0][0].json).toEqual({ output: { text: 'success' } });
|
||||
expect(result[0][1].json).toEqual({ error: 'Test error' });
|
||||
});
|
||||
|
||||
it('should throw error in batch processing when continueOnFail is false', 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 mockTools = [mock<Tool>()];
|
||||
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue(mockTools);
|
||||
|
||||
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 === 'needsFallback') return false;
|
||||
if (param === 'options')
|
||||
return {
|
||||
systemMessage: 'You are a helpful assistant',
|
||||
maxIterations: 10,
|
||||
returnIntermediateSteps: false,
|
||||
passthroughBinaryImages: true,
|
||||
};
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
mockContext.continueOnFail.mockReturnValue(false);
|
||||
|
||||
const mockExecutor = {
|
||||
invoke: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success' }) })
|
||||
.mockRejectedValueOnce(new Error('Test error')),
|
||||
};
|
||||
|
||||
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
describe('streaming', () => {
|
||||
let mockNode: INode;
|
||||
let mockModel: BaseChatModel;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockNode = mock<INode>();
|
||||
mockNode.typeVersion = 2.2;
|
||||
mockContext.getNode.mockReturnValue(mockNode);
|
||||
mockContext.getInputData.mockReturnValue([{ json: { text: 'test input' } }]);
|
||||
|
||||
mockModel = mock<BaseChatModel>();
|
||||
mockModel.bindTools = jest.fn();
|
||||
mockModel.lc_namespace = ['chat_models'];
|
||||
mockContext.getInputConnectionData.mockImplementation(async (type, _index) => {
|
||||
if (type === 'ai_languageModel') return mockModel;
|
||||
if (type === 'ai_memory') return undefined;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
|
||||
if (param === 'enableStreaming') return true;
|
||||
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;
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle streaming when enableStreaming is true', async () => {
|
||||
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
|
||||
jest.spyOn(outputParserModule, 'getOptionalOutputParser').mockResolvedValue(undefined);
|
||||
mockContext.isStreaming.mockReturnValue(true);
|
||||
|
||||
// Mock async generator for streamEvents
|
||||
const mockStreamEvents = async function* () {
|
||||
yield {
|
||||
event: 'on_chat_model_stream',
|
||||
data: {
|
||||
chunk: {
|
||||
content: 'Hello ',
|
||||
},
|
||||
},
|
||||
};
|
||||
yield {
|
||||
event: 'on_chat_model_stream',
|
||||
data: {
|
||||
chunk: {
|
||||
content: 'world!',
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const mockExecutor = {
|
||||
streamEvents: jest.fn().mockReturnValue(mockStreamEvents()),
|
||||
};
|
||||
|
||||
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
||||
|
||||
const result = await toolsAgentExecute.call(mockContext);
|
||||
|
||||
expect(mockContext.sendChunk).toHaveBeenCalledWith('begin', 0);
|
||||
expect(mockContext.sendChunk).toHaveBeenCalledWith('item', 0, 'Hello ');
|
||||
expect(mockContext.sendChunk).toHaveBeenCalledWith('item', 0, 'world!');
|
||||
expect(mockContext.sendChunk).toHaveBeenCalledWith('end', 0);
|
||||
expect(mockExecutor.streamEvents).toHaveBeenCalledTimes(1);
|
||||
expect(result[0]).toHaveLength(1);
|
||||
expect(result[0][0].json.output).toBe('Hello world!');
|
||||
});
|
||||
|
||||
it('should capture intermediate steps during streaming when returnIntermediateSteps is true', async () => {
|
||||
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
|
||||
jest.spyOn(outputParserModule, 'getOptionalOutputParser').mockResolvedValue(undefined);
|
||||
|
||||
mockContext.isStreaming.mockReturnValue(true);
|
||||
|
||||
mockContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
|
||||
if (param === 'enableStreaming') return true;
|
||||
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: true, // Enable intermediate steps
|
||||
passthroughBinaryImages: true,
|
||||
};
|
||||
return defaultValue;
|
||||
});
|
||||
|
||||
// Simulate an AIMessage class instance (has toJSON and direct properties)
|
||||
const fakeAIMessage = {
|
||||
content: 'I need to call a tool',
|
||||
tool_calls: [
|
||||
{
|
||||
id: 'call_123',
|
||||
name: 'TestTool',
|
||||
args: { input: 'test data' },
|
||||
type: 'function',
|
||||
},
|
||||
],
|
||||
additional_kwargs: {},
|
||||
response_metadata: {},
|
||||
id: 'msg_abc',
|
||||
toJSON() {
|
||||
return {
|
||||
lc: 1,
|
||||
type: 'constructor',
|
||||
id: ['langchain_core', 'messages', 'AIMessage'],
|
||||
kwargs: {
|
||||
content: this.content,
|
||||
tool_calls: this.tool_calls,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
// Mock async generator for streamEvents with tool calls
|
||||
const mockStreamEvents = async function* () {
|
||||
// LLM response with tool call (using the fake AIMessage instance)
|
||||
yield {
|
||||
event: 'on_chat_model_end',
|
||||
data: {
|
||||
output: fakeAIMessage,
|
||||
},
|
||||
};
|
||||
// Tool execution result
|
||||
yield {
|
||||
event: 'on_tool_end',
|
||||
name: 'TestTool',
|
||||
data: {
|
||||
output: 'Tool execution result',
|
||||
},
|
||||
};
|
||||
// Final LLM response
|
||||
yield {
|
||||
event: 'on_chat_model_stream',
|
||||
data: {
|
||||
chunk: {
|
||||
content: 'Final response',
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const mockExecutor = {
|
||||
streamEvents: jest.fn().mockReturnValue(mockStreamEvents()),
|
||||
};
|
||||
|
||||
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
||||
|
||||
const result = await toolsAgentExecute.call(mockContext);
|
||||
|
||||
expect(result[0]).toHaveLength(1);
|
||||
expect(result[0][0].json.output).toBe('Final response');
|
||||
|
||||
// Check intermediate steps
|
||||
expect(result[0][0].json.intermediateSteps).toBeDefined();
|
||||
expect(result[0][0].json.intermediateSteps).toHaveLength(1);
|
||||
|
||||
const step = (result[0][0].json.intermediateSteps as any[])[0];
|
||||
expect(step.action).toBeDefined();
|
||||
expect(step.action.tool).toBe('TestTool');
|
||||
expect(step.action.toolInput).toEqual({ input: 'test data' });
|
||||
expect(step.action.toolCallId).toBe('call_123');
|
||||
expect(step.action.type).toBe('function');
|
||||
expect(step.action.messageLog).toBeDefined();
|
||||
expect(step.observation).toBe('Tool execution result');
|
||||
|
||||
const messageLogEntry = step.action.messageLog[0];
|
||||
expect(messageLogEntry.content).toBe('I need to call a tool');
|
||||
expect(messageLogEntry.tool_calls).toEqual([
|
||||
{ id: 'call_123', name: 'TestTool', args: { input: 'test data' }, type: 'function' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('should use regular execution on version 2.2 when enableStreaming is false', async () => {
|
||||
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
|
||||
jest.spyOn(outputParserModule, 'getOptionalOutputParser').mockResolvedValue(undefined);
|
||||
|
||||
const mockExecutor = {
|
||||
invoke: jest.fn().mockResolvedValue({ output: 'Regular response' }),
|
||||
streamEvents: jest.fn(),
|
||||
};
|
||||
|
||||
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
||||
|
||||
const result = await toolsAgentExecute.call(mockContext);
|
||||
|
||||
expect(mockContext.sendChunk).not.toHaveBeenCalled();
|
||||
expect(mockExecutor.invoke).toHaveBeenCalledTimes(1);
|
||||
expect(mockExecutor.streamEvents).not.toHaveBeenCalled();
|
||||
expect(result[0][0].json.output).toBe('Regular response');
|
||||
});
|
||||
|
||||
it('should use regular execution on version 2.2 when streaming is not available', async () => {
|
||||
mockContext.isStreaming.mockReturnValue(false);
|
||||
|
||||
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
|
||||
jest.spyOn(outputParserModule, 'getOptionalOutputParser').mockResolvedValue(undefined);
|
||||
|
||||
const mockExecutor = {
|
||||
invoke: jest.fn().mockResolvedValue({ output: 'Regular response' }),
|
||||
streamEvents: jest.fn(),
|
||||
};
|
||||
|
||||
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
||||
|
||||
const result = await toolsAgentExecute.call(mockContext);
|
||||
|
||||
expect(mockContext.sendChunk).not.toHaveBeenCalled();
|
||||
expect(mockExecutor.invoke).toHaveBeenCalledTimes(1);
|
||||
expect(mockExecutor.streamEvents).not.toHaveBeenCalled();
|
||||
expect(result[0][0].json.output).toBe('Regular response');
|
||||
});
|
||||
|
||||
it('should respect context window length from memory in streaming mode', async () => {
|
||||
const mockMemory = {
|
||||
loadMemoryVariables: jest.fn().mockResolvedValue({
|
||||
chat_history: [
|
||||
{ role: 'human', content: 'Message 1' },
|
||||
{ role: 'ai', content: 'Response 1' },
|
||||
],
|
||||
}),
|
||||
chatHistory: {
|
||||
getMessages: jest.fn().mockResolvedValue([
|
||||
{ role: 'human', content: 'Message 1' },
|
||||
{ role: 'ai', content: 'Response 1' },
|
||||
{ role: 'human', content: 'Message 2' },
|
||||
{ role: 'ai', content: 'Response 2' },
|
||||
]),
|
||||
},
|
||||
};
|
||||
|
||||
jest.spyOn(commonModule, 'getOptionalMemory').mockResolvedValue(mockMemory as any);
|
||||
|
||||
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
|
||||
jest.spyOn(outputParserModule, 'getOptionalOutputParser').mockResolvedValue(undefined);
|
||||
mockContext.isStreaming.mockReturnValue(true);
|
||||
|
||||
const mockStreamEvents = async function* () {
|
||||
yield {
|
||||
event: 'on_chat_model_stream',
|
||||
data: {
|
||||
chunk: {
|
||||
content: 'Response',
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const mockExecutor = {
|
||||
streamEvents: jest.fn().mockReturnValue(mockStreamEvents()),
|
||||
};
|
||||
|
||||
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
||||
|
||||
await toolsAgentExecute.call(mockContext);
|
||||
|
||||
// Verify that memory.loadMemoryVariables was called instead of chatHistory.getMessages
|
||||
expect(mockMemory.loadMemoryVariables).toHaveBeenCalledWith({});
|
||||
expect(mockMemory.chatHistory.getMessages).not.toHaveBeenCalled();
|
||||
|
||||
// Verify that streamEvents was called with the filtered chat history from loadMemoryVariables
|
||||
expect(mockExecutor.streamEvents).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chat_history: [
|
||||
{ role: 'human', content: 'Message 1' },
|
||||
{ role: 'ai', content: 'Response 1' },
|
||||
],
|
||||
}),
|
||||
expect.any(Object),
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle mixed message content types in streaming', async () => {
|
||||
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
|
||||
jest.spyOn(outputParserModule, 'getOptionalOutputParser').mockResolvedValue(undefined);
|
||||
mockContext.isStreaming.mockReturnValue(true);
|
||||
|
||||
// Mock async generator for streamEvents with mixed content types
|
||||
const mockStreamEvents = async function* () {
|
||||
// Message with array content including text and non-text types
|
||||
yield {
|
||||
event: 'on_chat_model_stream',
|
||||
data: {
|
||||
chunk: {
|
||||
content: [
|
||||
{ type: 'text', text: 'Hello ' },
|
||||
{ type: 'thinking', content: 'This is thinking content' },
|
||||
{ type: 'text', text: 'world!' },
|
||||
{ type: 'image', url: 'data:image/png;base64,abc123' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const mockExecutor = {
|
||||
streamEvents: jest.fn().mockReturnValue(mockStreamEvents()),
|
||||
};
|
||||
|
||||
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
||||
|
||||
const result = await toolsAgentExecute.call(mockContext);
|
||||
|
||||
expect(mockContext.sendChunk).toHaveBeenCalledWith('begin', 0);
|
||||
expect(mockContext.sendChunk).toHaveBeenCalledWith('item', 0, 'Hello world!');
|
||||
expect(mockContext.sendChunk).toHaveBeenCalledWith('end', 0);
|
||||
expect(result[0]).toHaveLength(1);
|
||||
expect(result[0][0].json.output).toBe('Hello world!');
|
||||
});
|
||||
|
||||
it('should handle string content in streaming', async () => {
|
||||
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
|
||||
jest.spyOn(outputParserModule, 'getOptionalOutputParser').mockResolvedValue(undefined);
|
||||
mockContext.isStreaming.mockReturnValue(true);
|
||||
|
||||
// Mock async generator for streamEvents with string content
|
||||
const mockStreamEvents = async function* () {
|
||||
yield {
|
||||
event: 'on_chat_model_stream',
|
||||
data: {
|
||||
chunk: {
|
||||
content: 'Direct string content',
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const mockExecutor = {
|
||||
streamEvents: jest.fn().mockReturnValue(mockStreamEvents()),
|
||||
};
|
||||
|
||||
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
||||
|
||||
const result = await toolsAgentExecute.call(mockContext);
|
||||
|
||||
expect(mockContext.sendChunk).toHaveBeenCalledWith('begin', 0);
|
||||
expect(mockContext.sendChunk).toHaveBeenCalledWith('item', 0, 'Direct string content');
|
||||
expect(mockContext.sendChunk).toHaveBeenCalledWith('end', 0);
|
||||
expect(result[0]).toHaveLength(1);
|
||||
expect(result[0][0].json.output).toBe('Direct string content');
|
||||
});
|
||||
|
||||
it('should ignore non-text message types in array content', async () => {
|
||||
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
|
||||
jest.spyOn(outputParserModule, 'getOptionalOutputParser').mockResolvedValue(undefined);
|
||||
mockContext.isStreaming.mockReturnValue(true);
|
||||
|
||||
// Mock async generator with only non-text content
|
||||
const mockStreamEvents = async function* () {
|
||||
yield {
|
||||
event: 'on_chat_model_stream',
|
||||
data: {
|
||||
chunk: {
|
||||
content: [
|
||||
{ type: 'thinking', content: 'This is thinking content' },
|
||||
{ type: 'image', url: 'data:image/png;base64,abc123' },
|
||||
{ type: 'audio', data: 'audio-data' },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const mockExecutor = {
|
||||
streamEvents: jest.fn().mockReturnValue(mockStreamEvents()),
|
||||
};
|
||||
|
||||
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
||||
|
||||
const result = await toolsAgentExecute.call(mockContext);
|
||||
|
||||
expect(mockContext.sendChunk).toHaveBeenCalledWith('begin', 0);
|
||||
expect(mockContext.sendChunk).toHaveBeenCalledWith('item', 0, '');
|
||||
expect(mockContext.sendChunk).toHaveBeenCalledWith('end', 0);
|
||||
expect(result[0]).toHaveLength(1);
|
||||
expect(result[0][0].json.output).toBe('');
|
||||
});
|
||||
|
||||
it('should handle empty chunk content gracefully', async () => {
|
||||
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
|
||||
jest.spyOn(outputParserModule, 'getOptionalOutputParser').mockResolvedValue(undefined);
|
||||
mockContext.isStreaming.mockReturnValue(true);
|
||||
|
||||
// Mock async generator with empty content
|
||||
const mockStreamEvents = async function* () {
|
||||
yield {
|
||||
event: 'on_chat_model_stream',
|
||||
data: {
|
||||
chunk: {
|
||||
content: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
yield {
|
||||
event: 'on_chat_model_stream',
|
||||
data: {
|
||||
chunk: {},
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const mockExecutor = {
|
||||
streamEvents: jest.fn().mockReturnValue(mockStreamEvents()),
|
||||
};
|
||||
|
||||
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
||||
|
||||
const result = await toolsAgentExecute.call(mockContext);
|
||||
|
||||
expect(mockContext.sendChunk).toHaveBeenCalledWith('begin', 0);
|
||||
expect(mockContext.sendChunk).toHaveBeenCalledWith('end', 0);
|
||||
expect(result[0]).toHaveLength(1);
|
||||
expect(result[0][0].json.output).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
it('should process items if SupplyDataContext is passed and isStreaming is not set', async () => {
|
||||
const mockSupplyDataContext = mock<ISupplyDataFunctions>();
|
||||
|
||||
// @ts-expect-error isStreaming is not supported by SupplyDataFunctions, but mock object still resolves it
|
||||
mockSupplyDataContext.isStreaming = undefined;
|
||||
|
||||
mockSupplyDataContext.logger = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
|
||||
const mockNode = mock<INode>();
|
||||
mockNode.typeVersion = 2.2; // version where streaming is supported
|
||||
mockSupplyDataContext.getNode.mockReturnValue(mockNode);
|
||||
mockSupplyDataContext.getInputData.mockReturnValue([{ json: { text: 'test input 1' } }]);
|
||||
|
||||
const mockModel = mock<BaseChatModel>();
|
||||
mockModel.bindTools = jest.fn();
|
||||
mockModel.lc_namespace = ['chat_models'];
|
||||
mockSupplyDataContext.getInputConnectionData.mockResolvedValue(mockModel);
|
||||
|
||||
const mockTools = [mock<Tool>()];
|
||||
jest.spyOn(helpers, 'getConnectedTools').mockResolvedValue(mockTools);
|
||||
|
||||
// Mock getNodeParameter to return default values
|
||||
mockSupplyDataContext.getNodeParameter.mockImplementation((param, _i, defaultValue) => {
|
||||
if (param === 'enableStreaming') return true;
|
||||
if (param === 'text') return 'test input';
|
||||
if (param === 'needsFallback') return false;
|
||||
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: { text: 'success 1' } }),
|
||||
};
|
||||
|
||||
jest.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(mockExecutor as any);
|
||||
|
||||
const result = await toolsAgentExecute.call(mockSupplyDataContext);
|
||||
|
||||
expect(mockExecutor.invoke).toHaveBeenCalledTimes(1);
|
||||
expect(result[0]).toHaveLength(1);
|
||||
expect(result[0][0].json).toEqual({ output: { text: 'success 1' } });
|
||||
});
|
||||
});
|
||||
+388
@@ -0,0 +1,388 @@
|
||||
import type { RequestResponseMetadata } from '@utils/agent-execution';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import {
|
||||
sleep,
|
||||
type IExecuteFunctions,
|
||||
type INode,
|
||||
type EngineRequest,
|
||||
type EngineResponse,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { toolsAgentExecute } from '../../agents/ToolsAgent/V3/execute';
|
||||
import * as helpers from '../../agents/ToolsAgent/V3/helpers';
|
||||
|
||||
// Mock the helper modules
|
||||
jest.mock('../../agents/ToolsAgent/V3/helpers', () => ({
|
||||
buildExecutionContext: jest.fn(),
|
||||
executeBatch: jest.fn(),
|
||||
checkMaxIterations: jest.fn(),
|
||||
buildResponseMetadata: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock langchain modules
|
||||
jest.mock('@langchain/classic/agents', () => ({
|
||||
createToolCallingAgent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@langchain/core/runnables', () => ({
|
||||
RunnableSequence: {
|
||||
from: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('n8n-workflow', () => ({
|
||||
...jest.requireActual('n8n-workflow'),
|
||||
sleep: jest.fn(),
|
||||
}));
|
||||
|
||||
const mockContext = mock<IExecuteFunctions>();
|
||||
const mockNode = mock<INode>();
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockContext.getNode.mockReturnValue(mockNode);
|
||||
mockContext.logger = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
describe('toolsAgentExecute V3 - Execute Function Logic', () => {
|
||||
it('should build execution context and process single batch', async () => {
|
||||
const mockExecutionContext = {
|
||||
items: [{ json: { text: 'test input 1' } }],
|
||||
batchSize: 1,
|
||||
delayBetweenBatches: 0,
|
||||
needsFallback: false,
|
||||
model: {} as any,
|
||||
fallbackModel: null,
|
||||
memory: undefined,
|
||||
};
|
||||
|
||||
const mockBatchResult = {
|
||||
returnData: [{ json: { output: 'success 1' }, pairedItem: { item: 0 } }],
|
||||
request: undefined,
|
||||
};
|
||||
|
||||
jest.spyOn(helpers, 'buildExecutionContext').mockResolvedValue(mockExecutionContext);
|
||||
jest.spyOn(helpers, 'executeBatch').mockResolvedValue(mockBatchResult);
|
||||
|
||||
const result = await toolsAgentExecute.call(mockContext);
|
||||
|
||||
expect(helpers.buildExecutionContext).toHaveBeenCalledWith(mockContext);
|
||||
expect(helpers.executeBatch).toHaveBeenCalledTimes(1);
|
||||
expect(helpers.executeBatch).toHaveBeenCalledWith(
|
||||
mockContext,
|
||||
mockExecutionContext.items.slice(0, 1),
|
||||
0,
|
||||
mockExecutionContext.model,
|
||||
mockExecutionContext.fallbackModel,
|
||||
mockExecutionContext.memory,
|
||||
undefined,
|
||||
);
|
||||
expect(result).toEqual([[{ json: { output: 'success 1' }, pairedItem: { item: 0 } }]]);
|
||||
});
|
||||
|
||||
it('should process multiple batches sequentially', async () => {
|
||||
const mockExecutionContext = {
|
||||
items: [
|
||||
{ json: { text: 'test input 1' } },
|
||||
{ json: { text: 'test input 2' } },
|
||||
{ json: { text: 'test input 3' } },
|
||||
],
|
||||
batchSize: 2,
|
||||
delayBetweenBatches: 0,
|
||||
needsFallback: false,
|
||||
model: {} as any,
|
||||
fallbackModel: null,
|
||||
memory: undefined,
|
||||
};
|
||||
|
||||
const mockBatchResult1 = {
|
||||
returnData: [
|
||||
{ json: { output: 'success 1' }, pairedItem: { item: 0 } },
|
||||
{ json: { output: 'success 2' }, pairedItem: { item: 1 } },
|
||||
],
|
||||
request: undefined,
|
||||
};
|
||||
|
||||
const mockBatchResult2 = {
|
||||
returnData: [{ json: { output: 'success 3' }, pairedItem: { item: 2 } }],
|
||||
request: undefined,
|
||||
};
|
||||
|
||||
jest.spyOn(helpers, 'buildExecutionContext').mockResolvedValue(mockExecutionContext);
|
||||
jest
|
||||
.spyOn(helpers, 'executeBatch')
|
||||
.mockResolvedValueOnce(mockBatchResult1)
|
||||
.mockResolvedValueOnce(mockBatchResult2);
|
||||
|
||||
const result = await toolsAgentExecute.call(mockContext);
|
||||
|
||||
expect(helpers.executeBatch).toHaveBeenCalledTimes(2);
|
||||
expect(helpers.executeBatch).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
mockContext,
|
||||
mockExecutionContext.items.slice(0, 2),
|
||||
0,
|
||||
mockExecutionContext.model,
|
||||
mockExecutionContext.fallbackModel,
|
||||
mockExecutionContext.memory,
|
||||
undefined,
|
||||
);
|
||||
expect(helpers.executeBatch).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
mockContext,
|
||||
mockExecutionContext.items.slice(2, 3),
|
||||
2,
|
||||
mockExecutionContext.model,
|
||||
mockExecutionContext.fallbackModel,
|
||||
mockExecutionContext.memory,
|
||||
undefined,
|
||||
);
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{ json: { output: 'success 1' }, pairedItem: { item: 0 } },
|
||||
{ json: { output: 'success 2' }, pairedItem: { item: 1 } },
|
||||
{ json: { output: 'success 3' }, pairedItem: { item: 2 } },
|
||||
],
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return request when batch returns tool call request', async () => {
|
||||
const mockExecutionContext = {
|
||||
items: [{ json: { text: 'test input 1' } }],
|
||||
batchSize: 1,
|
||||
delayBetweenBatches: 0,
|
||||
needsFallback: false,
|
||||
model: {} as any,
|
||||
fallbackModel: null,
|
||||
memory: undefined,
|
||||
};
|
||||
|
||||
const mockRequest: EngineRequest<RequestResponseMetadata> = {
|
||||
actions: [
|
||||
{
|
||||
actionType: 'ExecutionNodeAction' as const,
|
||||
nodeName: 'Test Tool',
|
||||
input: { input: 'test data' },
|
||||
type: 'ai_tool',
|
||||
id: 'call_123',
|
||||
metadata: { itemIndex: 0 },
|
||||
},
|
||||
],
|
||||
metadata: { previousRequests: [] },
|
||||
};
|
||||
|
||||
const mockBatchResult = {
|
||||
returnData: [],
|
||||
request: mockRequest,
|
||||
};
|
||||
|
||||
jest.spyOn(helpers, 'buildExecutionContext').mockResolvedValue(mockExecutionContext);
|
||||
jest.spyOn(helpers, 'executeBatch').mockResolvedValue(mockBatchResult);
|
||||
|
||||
const result = await toolsAgentExecute.call(mockContext);
|
||||
|
||||
expect(result).toEqual(mockRequest);
|
||||
});
|
||||
|
||||
it('should merge requests from multiple batches', async () => {
|
||||
const mockExecutionContext = {
|
||||
items: [{ json: { text: 'test input 1' } }, { json: { text: 'test input 2' } }],
|
||||
batchSize: 1,
|
||||
delayBetweenBatches: 0,
|
||||
needsFallback: false,
|
||||
model: {} as any,
|
||||
fallbackModel: null,
|
||||
memory: undefined,
|
||||
};
|
||||
|
||||
const mockRequest1: EngineRequest<RequestResponseMetadata> = {
|
||||
actions: [
|
||||
{
|
||||
actionType: 'ExecutionNodeAction' as const,
|
||||
nodeName: 'Test Tool 1',
|
||||
input: { input: 'test data 1' },
|
||||
type: 'ai_tool',
|
||||
id: 'call_123',
|
||||
metadata: { itemIndex: 0 },
|
||||
},
|
||||
],
|
||||
metadata: { previousRequests: [] },
|
||||
};
|
||||
|
||||
const mockRequest2: EngineRequest<RequestResponseMetadata> = {
|
||||
actions: [
|
||||
{
|
||||
actionType: 'ExecutionNodeAction' as const,
|
||||
nodeName: 'Test Tool 2',
|
||||
input: { input: 'test data 2' },
|
||||
type: 'ai_tool',
|
||||
id: 'call_456',
|
||||
metadata: { itemIndex: 1 },
|
||||
},
|
||||
],
|
||||
metadata: { previousRequests: [] },
|
||||
};
|
||||
|
||||
jest.spyOn(helpers, 'buildExecutionContext').mockResolvedValue(mockExecutionContext);
|
||||
jest
|
||||
.spyOn(helpers, 'executeBatch')
|
||||
.mockResolvedValueOnce({ returnData: [], request: mockRequest1 })
|
||||
.mockResolvedValueOnce({ returnData: [], request: mockRequest2 });
|
||||
|
||||
const result = (await toolsAgentExecute.call(
|
||||
mockContext,
|
||||
)) as EngineRequest<RequestResponseMetadata>;
|
||||
|
||||
expect(result.actions).toHaveLength(2);
|
||||
expect(result.actions[0].nodeName).toBe('Test Tool 1');
|
||||
expect(result.actions[1].nodeName).toBe('Test Tool 2');
|
||||
});
|
||||
|
||||
it('should apply delay between batches when configured', async () => {
|
||||
const sleepMock = sleep as jest.MockedFunction<typeof sleep>;
|
||||
sleepMock.mockResolvedValue(undefined);
|
||||
|
||||
const mockExecutionContext = {
|
||||
items: [{ json: { text: 'test input 1' } }, { json: { text: 'test input 2' } }],
|
||||
batchSize: 1,
|
||||
delayBetweenBatches: 1000,
|
||||
needsFallback: false,
|
||||
model: {} as any,
|
||||
fallbackModel: null,
|
||||
memory: undefined,
|
||||
};
|
||||
|
||||
const mockBatchResult = {
|
||||
returnData: [{ json: { output: 'success' }, pairedItem: { item: 0 } }],
|
||||
request: undefined,
|
||||
};
|
||||
|
||||
jest.spyOn(helpers, 'buildExecutionContext').mockResolvedValue(mockExecutionContext);
|
||||
jest.spyOn(helpers, 'executeBatch').mockResolvedValue(mockBatchResult);
|
||||
|
||||
await toolsAgentExecute.call(mockContext);
|
||||
|
||||
expect(sleepMock).toHaveBeenCalledWith(1000);
|
||||
expect(sleepMock).toHaveBeenCalledTimes(1); // Only between batches, not after the last one
|
||||
});
|
||||
|
||||
it('should not apply delay after last batch', async () => {
|
||||
const sleepMock = sleep as jest.MockedFunction<typeof sleep>;
|
||||
sleepMock.mockResolvedValue(undefined);
|
||||
|
||||
const mockExecutionContext = {
|
||||
items: [{ json: { text: 'test input 1' } }],
|
||||
batchSize: 1,
|
||||
delayBetweenBatches: 1000,
|
||||
needsFallback: false,
|
||||
model: {} as any,
|
||||
fallbackModel: null,
|
||||
memory: undefined,
|
||||
};
|
||||
|
||||
const mockBatchResult = {
|
||||
returnData: [{ json: { output: 'success' }, pairedItem: { item: 0 } }],
|
||||
request: undefined,
|
||||
};
|
||||
|
||||
jest.spyOn(helpers, 'buildExecutionContext').mockResolvedValue(mockExecutionContext);
|
||||
jest.spyOn(helpers, 'executeBatch').mockResolvedValue(mockBatchResult);
|
||||
|
||||
await toolsAgentExecute.call(mockContext);
|
||||
|
||||
expect(sleepMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should pass response parameter to executeBatch', async () => {
|
||||
const mockExecutionContext = {
|
||||
items: [{ json: { text: 'test input 1' } }],
|
||||
batchSize: 1,
|
||||
delayBetweenBatches: 0,
|
||||
needsFallback: false,
|
||||
model: {} as any,
|
||||
fallbackModel: null,
|
||||
memory: undefined,
|
||||
};
|
||||
|
||||
const mockBatchResult = {
|
||||
returnData: [{ json: { output: 'success' }, pairedItem: { item: 0 } }],
|
||||
request: undefined,
|
||||
};
|
||||
|
||||
const mockResponse: EngineResponse<RequestResponseMetadata> = {
|
||||
actionResponses: [
|
||||
{
|
||||
action: {
|
||||
id: 'call_123',
|
||||
nodeName: 'Test Tool',
|
||||
input: { input: 'test data', id: 'call_123' },
|
||||
metadata: { itemIndex: 0 },
|
||||
actionType: 'ExecutionNodeAction',
|
||||
type: 'ai_tool',
|
||||
},
|
||||
data: {
|
||||
data: { ai_tool: [[{ json: { result: 'tool result' } }]] },
|
||||
executionTime: 0,
|
||||
startTime: 0,
|
||||
executionIndex: 0,
|
||||
source: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
metadata: { itemIndex: 0, previousRequests: [] },
|
||||
};
|
||||
|
||||
jest.spyOn(helpers, 'buildExecutionContext').mockResolvedValue(mockExecutionContext);
|
||||
jest.spyOn(helpers, 'executeBatch').mockResolvedValue(mockBatchResult);
|
||||
|
||||
await toolsAgentExecute.call(mockContext, mockResponse);
|
||||
|
||||
expect(helpers.executeBatch).toHaveBeenCalledWith(
|
||||
mockContext,
|
||||
mockExecutionContext.items.slice(0, 1),
|
||||
0,
|
||||
mockExecutionContext.model,
|
||||
mockExecutionContext.fallbackModel,
|
||||
mockExecutionContext.memory,
|
||||
mockResponse,
|
||||
);
|
||||
});
|
||||
|
||||
it('should collect return data from multiple batches', async () => {
|
||||
const mockExecutionContext = {
|
||||
items: [{ json: { text: 'test input 1' } }, { json: { text: 'test input 2' } }],
|
||||
batchSize: 1,
|
||||
delayBetweenBatches: 0,
|
||||
needsFallback: false,
|
||||
model: {} as any,
|
||||
fallbackModel: null,
|
||||
memory: undefined,
|
||||
};
|
||||
|
||||
jest.spyOn(helpers, 'buildExecutionContext').mockResolvedValue(mockExecutionContext);
|
||||
jest
|
||||
.spyOn(helpers, 'executeBatch')
|
||||
.mockResolvedValueOnce({
|
||||
returnData: [{ json: { output: 'success 1' }, pairedItem: { item: 0 } }],
|
||||
request: undefined,
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
returnData: [{ json: { output: 'success 2' }, pairedItem: { item: 1 } }],
|
||||
request: undefined,
|
||||
});
|
||||
|
||||
const result = await toolsAgentExecute.call(mockContext);
|
||||
|
||||
expect(result).toEqual([
|
||||
[
|
||||
{ json: { output: 'success 1' }, pairedItem: { item: 0 } },
|
||||
{ json: { output: 'success 2' }, pairedItem: { item: 1 } },
|
||||
],
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,881 @@
|
||||
import type { BaseChatMemory } from '@langchain/community/memory/chat_memory';
|
||||
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
|
||||
import { HumanMessage } from '@langchain/core/messages';
|
||||
import type { BaseMessagePromptTemplateLike } from '@langchain/core/prompts';
|
||||
import { FakeLLM, FakeStreamingChatModel } from '@langchain/core/utils/testing';
|
||||
import { Buffer } from 'buffer';
|
||||
import { mock } from 'jest-mock-extended';
|
||||
import type { AgentAction, AgentFinish } from '@langchain/classic/agents';
|
||||
import type { ToolsAgentAction } from '@langchain/classic/dist/agents/tool_calling/output_parser';
|
||||
import type { Tool } from '@langchain/classic/tools';
|
||||
import type { IExecuteFunctions, INode } from 'n8n-workflow';
|
||||
import { NodeOperationError, BINARY_ENCODING, NodeConnectionTypes } from 'n8n-workflow';
|
||||
import type { ZodType } from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { N8nOutputParser } from '@utils/output_parsers/N8nOutputParser';
|
||||
|
||||
import {
|
||||
getOutputParserSchema,
|
||||
extractBinaryMessages,
|
||||
fixEmptyContentMessage,
|
||||
handleParsedStepOutput,
|
||||
getChatModel,
|
||||
getOptionalMemory,
|
||||
prepareMessages,
|
||||
preparePrompt,
|
||||
getTools,
|
||||
getAgentStepsParser,
|
||||
handleAgentFinishOutput,
|
||||
} from '../../agents/ToolsAgent/common';
|
||||
|
||||
function getFakeOutputParser(returnSchema?: ZodType): N8nOutputParser {
|
||||
const fakeOutputParser = mock<N8nOutputParser>();
|
||||
(fakeOutputParser.getSchema as jest.Mock).mockReturnValue(returnSchema);
|
||||
return fakeOutputParser;
|
||||
}
|
||||
|
||||
function createMockOutputParser(parseReturnValue?: Record<string, unknown>): N8nOutputParser {
|
||||
const mockParser = mock<N8nOutputParser>();
|
||||
(mockParser.parse as jest.Mock).mockResolvedValue(parseReturnValue);
|
||||
|
||||
return mockParser;
|
||||
}
|
||||
|
||||
const mockHelpers = mock<IExecuteFunctions['helpers']>();
|
||||
const mockContext = mock<IExecuteFunctions>({ helpers: mockHelpers });
|
||||
|
||||
beforeEach(() => jest.resetAllMocks());
|
||||
|
||||
describe('getOutputParserSchema', () => {
|
||||
it('should return a default schema if getSchema returns undefined', () => {
|
||||
const schema = getOutputParserSchema(getFakeOutputParser(undefined));
|
||||
// The default schema requires a "text" field.
|
||||
expect(() => schema.parse({})).toThrow();
|
||||
expect(schema.parse({ text: 'hello' })).toEqual({ text: 'hello' });
|
||||
});
|
||||
|
||||
it('should return the custom schema if provided', () => {
|
||||
const customSchema = z.object({ custom: z.number() });
|
||||
|
||||
const schema = getOutputParserSchema(getFakeOutputParser(customSchema));
|
||||
expect(() => schema.parse({ custom: 'not a number' })).toThrow();
|
||||
expect(schema.parse({ custom: 123 })).toEqual({ custom: 123 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractBinaryMessages', () => {
|
||||
it('should extract a binary message from the input data when no id is provided', async () => {
|
||||
const fakeItem = {
|
||||
json: {},
|
||||
binary: {
|
||||
img1: {
|
||||
mimeType: 'image/png',
|
||||
// simulate that data already includes 'base64'
|
||||
data: 'data:image/png;base64,sampledata',
|
||||
},
|
||||
},
|
||||
};
|
||||
mockContext.getInputData.mockReturnValue([fakeItem]);
|
||||
|
||||
const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0);
|
||||
// Expect the HumanMessage's content to be an array containing one binary message.
|
||||
expect(Array.isArray(humanMsg.content)).toBe(true);
|
||||
expect(humanMsg.content[0]).toEqual({
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/png;base64,sampledata' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract a binary message using binary stream if id is provided', async () => {
|
||||
const fakeItem = {
|
||||
json: {},
|
||||
binary: {
|
||||
img2: {
|
||||
mimeType: 'image/jpeg',
|
||||
id: '1234',
|
||||
data: 'nonsense',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
mockHelpers.getBinaryStream.mockResolvedValue(mock());
|
||||
mockHelpers.binaryToBuffer.mockResolvedValue(Buffer.from('fakebufferdata'));
|
||||
mockContext.getInputData.mockReturnValue([fakeItem]);
|
||||
|
||||
const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0);
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
expect(mockHelpers.getBinaryStream).toHaveBeenCalledWith('1234');
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
expect(mockHelpers.binaryToBuffer).toHaveBeenCalled();
|
||||
const expectedUrl = `data:image/jpeg;base64,${Buffer.from('fakebufferdata').toString(
|
||||
BINARY_ENCODING,
|
||||
)}`;
|
||||
expect(humanMsg.content[0]).toEqual({
|
||||
type: 'image_url',
|
||||
image_url: { url: expectedUrl },
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract markdown and CSV text files', async () => {
|
||||
const mdContent = '# Test Markdown\n\nThis is a test.';
|
||||
const csvContent = 'name,age\nJohn,30';
|
||||
const fakeItem = {
|
||||
json: {},
|
||||
binary: {
|
||||
markdown: {
|
||||
mimeType: 'text/markdown',
|
||||
fileName: 'test.md',
|
||||
data: `data:text/markdown;base64,${Buffer.from(mdContent).toString('base64')}`,
|
||||
},
|
||||
csv: {
|
||||
mimeType: 'text/csv',
|
||||
fileName: 'data.csv',
|
||||
data: `data:text/csv;base64,${Buffer.from(csvContent).toString('base64')}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
mockContext.getInputData.mockReturnValue([fakeItem]);
|
||||
|
||||
const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0);
|
||||
|
||||
expect(Array.isArray(humanMsg.content)).toBe(true);
|
||||
expect(humanMsg.content).toHaveLength(2);
|
||||
expect(humanMsg.content).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ type: 'text', text: `File: test.md\nContent:\n${mdContent}` },
|
||||
{ type: 'text', text: `File: data.csv\nContent:\n${csvContent}` },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should extract both images and text files together', async () => {
|
||||
const textContent = 'Some text content';
|
||||
const fakeItem = {
|
||||
json: {},
|
||||
binary: {
|
||||
image: {
|
||||
mimeType: 'image/png',
|
||||
fileName: 'test.png',
|
||||
data: 'imageData123',
|
||||
},
|
||||
text: {
|
||||
mimeType: 'text/plain',
|
||||
fileName: 'test.txt',
|
||||
data: `data:text/plain;base64,${Buffer.from(textContent).toString('base64')}`,
|
||||
},
|
||||
},
|
||||
};
|
||||
mockContext.getInputData.mockReturnValue([fakeItem]);
|
||||
|
||||
const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0);
|
||||
|
||||
expect(Array.isArray(humanMsg.content)).toBe(true);
|
||||
expect(humanMsg.content).toHaveLength(2);
|
||||
expect(humanMsg.content).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
type: 'image_url',
|
||||
image_url: { url: 'data:image/png;base64,imageData123' },
|
||||
},
|
||||
{ type: 'text', text: `File: test.txt\nContent:\n${textContent}` },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should decode base64-encoded text files without prefix', async () => {
|
||||
const textContent = 'Hello world!';
|
||||
const fakeItem = {
|
||||
json: {},
|
||||
binary: {
|
||||
text: {
|
||||
mimeType: 'text/plain',
|
||||
fileName: 'test.txt',
|
||||
// Default n8n binary format: base64 without data URL prefix
|
||||
data: Buffer.from(textContent).toString('base64'),
|
||||
},
|
||||
},
|
||||
};
|
||||
mockContext.getInputData.mockReturnValue([fakeItem]);
|
||||
|
||||
const humanMsg: HumanMessage = await extractBinaryMessages(mockContext, 0);
|
||||
|
||||
expect(Array.isArray(humanMsg.content)).toBe(true);
|
||||
expect(humanMsg.content).toHaveLength(1);
|
||||
expect(humanMsg.content[0]).toEqual({
|
||||
type: 'text',
|
||||
text: `File: test.txt\nContent:\n${textContent}`,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('fixEmptyContentMessage', () => {
|
||||
it('should replace empty string inputs with empty objects', () => {
|
||||
// Cast to any to bypass type issues with AgentFinish/AgentAction.
|
||||
const fakeSteps: ToolsAgentAction[] = [
|
||||
{
|
||||
messageLog: [
|
||||
{
|
||||
content: [{ input: '' }, { input: { already: 'object' } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
] as unknown as ToolsAgentAction[];
|
||||
const fixed = fixEmptyContentMessage(fakeSteps) as ToolsAgentAction[];
|
||||
const messageContent = fixed?.[0]?.messageLog?.[0].content;
|
||||
|
||||
// Type assertion needed since we're extending MessageContentComplex
|
||||
expect((messageContent?.[0] as unknown as { input: unknown })?.input).toEqual({});
|
||||
expect((messageContent?.[1] as unknown as { input: unknown })?.input).toEqual({
|
||||
already: 'object',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleParsedStepOutput', () => {
|
||||
it('should stringify the output if memory is provided', () => {
|
||||
const output = { key: 'value' };
|
||||
const fakeMemory = mock<BaseChatMemory>();
|
||||
const result = handleParsedStepOutput(output, fakeMemory);
|
||||
expect(result.returnValues).toEqual({ output: JSON.stringify(output) });
|
||||
expect(result.log).toEqual('Final response formatted');
|
||||
});
|
||||
|
||||
it('should not stringify the output if memory is not provided', () => {
|
||||
const output = { key: 'value' };
|
||||
const result = handleParsedStepOutput(output);
|
||||
expect(result.returnValues).toEqual(output);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getChatModel', () => {
|
||||
it('should return the model if it is a valid chat model', async () => {
|
||||
// Cast fakeChatModel as any
|
||||
const fakeChatModel = mock<BaseChatModel>();
|
||||
fakeChatModel.bindTools = jest.fn();
|
||||
fakeChatModel.lc_namespace = ['chat_models'];
|
||||
mockContext.getInputConnectionData.mockResolvedValue(fakeChatModel);
|
||||
|
||||
const model = await getChatModel(mockContext);
|
||||
expect(model).toEqual(fakeChatModel);
|
||||
});
|
||||
|
||||
it('should throw if the model is not a valid chat model', async () => {
|
||||
const fakeInvalidModel = mock<BaseChatModel>(); // missing bindTools & lc_namespace
|
||||
fakeInvalidModel.lc_namespace = [];
|
||||
mockContext.getInputConnectionData.mockResolvedValue(fakeInvalidModel);
|
||||
mockContext.getNode.mockReturnValue(mock());
|
||||
await expect(getChatModel(mockContext)).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
|
||||
it('should return the first model when multiple models are connected and no index specified', async () => {
|
||||
const fakeChatModel1 = new FakeStreamingChatModel({});
|
||||
const fakeChatModel2 = new FakeStreamingChatModel({});
|
||||
|
||||
mockContext.getInputConnectionData.mockResolvedValue([fakeChatModel1, fakeChatModel2]);
|
||||
|
||||
const model = await getChatModel(mockContext);
|
||||
expect(model).toEqual(fakeChatModel2); // Should return the last model (reversed array)
|
||||
});
|
||||
|
||||
it('should return the model at specified index when multiple models are connected', async () => {
|
||||
const fakeChatModel1 = new FakeStreamingChatModel({});
|
||||
|
||||
const fakeChatModel2 = new FakeStreamingChatModel({});
|
||||
|
||||
mockContext.getInputConnectionData.mockResolvedValue([fakeChatModel1, fakeChatModel2]);
|
||||
|
||||
const model = await getChatModel(mockContext, 0);
|
||||
expect(model).toEqual(fakeChatModel2); // Should return the first model after reversal (index 0)
|
||||
});
|
||||
|
||||
it('should return the fallback model at index 1 when multiple models are connected', async () => {
|
||||
const fakeChatModel1 = new FakeStreamingChatModel({});
|
||||
const fakeChatModel2 = new FakeStreamingChatModel({});
|
||||
|
||||
mockContext.getInputConnectionData.mockResolvedValue([fakeChatModel1, fakeChatModel2]);
|
||||
|
||||
const model = await getChatModel(mockContext, 1);
|
||||
expect(model).toEqual(fakeChatModel1); // Should return the second model after reversal (index 1)
|
||||
});
|
||||
|
||||
it('should return undefined when requested index is out of bounds', async () => {
|
||||
const fakeChatModel1 = mock<BaseChatModel>();
|
||||
fakeChatModel1.bindTools = jest.fn();
|
||||
fakeChatModel1.lc_namespace = ['chat_models'];
|
||||
|
||||
mockContext.getInputConnectionData.mockResolvedValue([fakeChatModel1]);
|
||||
mockContext.getNode.mockReturnValue(mock());
|
||||
|
||||
const result = await getChatModel(mockContext, 2);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should throw error when single model does not support tools', async () => {
|
||||
const fakeInvalidModel = new FakeLLM({}); // doesn't support tool calls
|
||||
|
||||
mockContext.getInputConnectionData.mockResolvedValue(fakeInvalidModel);
|
||||
mockContext.getNode.mockReturnValue(mock());
|
||||
|
||||
await expect(getChatModel(mockContext)).rejects.toThrow(NodeOperationError);
|
||||
await expect(getChatModel(mockContext)).rejects.toThrow(
|
||||
'Tools Agent requires Chat Model which supports Tools calling',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error when model at specified index does not support tools', async () => {
|
||||
const fakeChatModel1 = new FakeStreamingChatModel({});
|
||||
const fakeInvalidModel = new FakeLLM({}); // doesn't support tool calls
|
||||
|
||||
mockContext.getInputConnectionData.mockResolvedValue([fakeChatModel1, fakeInvalidModel]);
|
||||
mockContext.getNode.mockReturnValue(mock());
|
||||
|
||||
await expect(getChatModel(mockContext, 0)).rejects.toThrow(NodeOperationError);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getOptionalMemory', () => {
|
||||
it('should return the memory if available', async () => {
|
||||
const fakeMemory = { some: 'memory' };
|
||||
mockContext.getInputConnectionData.mockResolvedValue(fakeMemory);
|
||||
|
||||
const memory = await getOptionalMemory(mockContext);
|
||||
expect(memory).toEqual(fakeMemory);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTools', () => {
|
||||
beforeEach(() => {
|
||||
const fakeTool = mock<Tool>();
|
||||
mockContext.getInputConnectionData
|
||||
.calledWith(NodeConnectionTypes.AiTool, 0)
|
||||
.mockResolvedValue([fakeTool]);
|
||||
});
|
||||
|
||||
it('should retrieve tools without appending if outputParser is not provided', async () => {
|
||||
const tools = await getTools(mockContext);
|
||||
|
||||
expect(tools.length).toEqual(1);
|
||||
});
|
||||
|
||||
it('should retrieve tools and append the structured output parser tool if outputParser is provided', async () => {
|
||||
const fakeOutputParser = getFakeOutputParser(z.object({ text: z.string() }));
|
||||
const tools = await getTools(mockContext, fakeOutputParser);
|
||||
// Our fake getConnectedTools returns one tool; with outputParser, one extra is appended.
|
||||
expect(tools.length).toEqual(2);
|
||||
const dynamicTool = tools.find((t) => t.name === 'format_final_json_response');
|
||||
expect(dynamicTool).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('prepareMessages', () => {
|
||||
it('should include a binary message if binary data is present and passthroughBinaryImages is true', async () => {
|
||||
const fakeItem = {
|
||||
json: {},
|
||||
binary: {
|
||||
img1: {
|
||||
mimeType: 'image/png',
|
||||
data: 'data:image/png;base64,sampledata',
|
||||
},
|
||||
},
|
||||
};
|
||||
mockContext.getInputData.mockReturnValue([fakeItem]);
|
||||
const messages = await prepareMessages(mockContext, 0, {
|
||||
systemMessage: 'Test system',
|
||||
passthroughBinaryImages: true,
|
||||
});
|
||||
// Check if any message is an instance of HumanMessage
|
||||
const hasBinaryMessage = messages.some(
|
||||
(m) => typeof m === 'object' && m instanceof HumanMessage,
|
||||
);
|
||||
expect(hasBinaryMessage).toBe(true);
|
||||
});
|
||||
|
||||
it('should not include a binary message if no binary data is present', async () => {
|
||||
const fakeItem = { json: {} }; // no binary key
|
||||
mockContext.getInputData.mockReturnValue([fakeItem]);
|
||||
const messages = await prepareMessages(mockContext, 0, {
|
||||
systemMessage: 'Test system',
|
||||
passthroughBinaryImages: true,
|
||||
});
|
||||
const hasHumanMessage = messages.some((m) => m instanceof HumanMessage);
|
||||
expect(hasHumanMessage).toBe(false);
|
||||
});
|
||||
|
||||
it('should not include a binary message if no image data is present', async () => {
|
||||
const fakeItem = {
|
||||
json: {},
|
||||
binary: {
|
||||
img1: {
|
||||
mimeType: 'application/pdf',
|
||||
data: 'data:application/pdf;base64,sampledata',
|
||||
},
|
||||
},
|
||||
};
|
||||
mockContext.getInputData.mockReturnValue([fakeItem]);
|
||||
mockContext.logger = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
};
|
||||
|
||||
const messages = await prepareMessages(mockContext, 0, {
|
||||
systemMessage: 'Test system',
|
||||
passthroughBinaryImages: true,
|
||||
});
|
||||
const hasHumanMessage = messages.some((m) => m instanceof HumanMessage);
|
||||
expect(hasHumanMessage).toBe(false);
|
||||
expect(mockContext.logger.debug).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not include system_message in prompt templates if not provided after version 1.9', async () => {
|
||||
const fakeItem = { json: {} };
|
||||
const mockNode = mock<INode>();
|
||||
mockNode.typeVersion = 1.9;
|
||||
mockContext.getInputData.mockReturnValue([fakeItem]);
|
||||
mockContext.getNode.mockReturnValue(mockNode);
|
||||
const messages = await prepareMessages(mockContext, 0, {});
|
||||
|
||||
expect(messages.length).toBe(3);
|
||||
expect(messages).not.toContainEqual(['system', '{system_message}']);
|
||||
});
|
||||
|
||||
it('should include system_message in prompt templates if provided after version 1.9', async () => {
|
||||
const fakeItem = { json: {} };
|
||||
const mockNode = mock<INode>();
|
||||
mockNode.typeVersion = 1.9;
|
||||
mockContext.getInputData.mockReturnValue([fakeItem]);
|
||||
mockContext.getNode.mockReturnValue(mockNode);
|
||||
|
||||
const messages = await prepareMessages(mockContext, 0, { systemMessage: 'Hello' });
|
||||
|
||||
expect(messages.length).toBe(4);
|
||||
expect(messages).toContainEqual(['system', '{system_message}']);
|
||||
});
|
||||
|
||||
it('should include system_message in prompt templates if not provided before version 1.9', async () => {
|
||||
const fakeItem = { json: {} };
|
||||
const mockNode = mock<INode>();
|
||||
mockNode.typeVersion = 1.8;
|
||||
mockContext.getInputData.mockReturnValue([fakeItem]);
|
||||
mockContext.getNode.mockReturnValue(mockNode);
|
||||
|
||||
const messages = await prepareMessages(mockContext, 0, {});
|
||||
|
||||
expect(messages.length).toBe(4);
|
||||
expect(messages).toContainEqual(['system', '{system_message}']);
|
||||
});
|
||||
|
||||
it('should include system_message with formatting_instructions in prompt templates if provided before version 1.9', async () => {
|
||||
const fakeItem = { json: {} };
|
||||
const mockNode = mock<INode>();
|
||||
mockNode.typeVersion = 1.8;
|
||||
mockContext.getInputData.mockReturnValue([fakeItem]);
|
||||
mockContext.getNode.mockReturnValue(mockNode);
|
||||
|
||||
const messages = await prepareMessages(mockContext, 0, {
|
||||
systemMessage: 'Hello',
|
||||
outputParser: mock<N8nOutputParser>(),
|
||||
});
|
||||
|
||||
expect(messages.length).toBe(4);
|
||||
expect(messages).toContainEqual(['system', '{system_message}\n\n{formatting_instructions}']);
|
||||
});
|
||||
|
||||
it('should add formatting instructions when omitting system message after version 1.9', async () => {
|
||||
const fakeItem = { json: {} };
|
||||
const mockNode = mock<INode>();
|
||||
mockNode.typeVersion = 1.9;
|
||||
mockContext.getInputData.mockReturnValue([fakeItem]);
|
||||
mockContext.getNode.mockReturnValue(mockNode);
|
||||
|
||||
const messages = await prepareMessages(mockContext, 0, {
|
||||
outputParser: mock<N8nOutputParser>(),
|
||||
});
|
||||
|
||||
expect(messages.length).toBe(4);
|
||||
expect(messages).toContainEqual(['system', '{formatting_instructions}']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('preparePrompt', () => {
|
||||
it('should return a ChatPromptTemplate instance', () => {
|
||||
const sampleMessages: BaseMessagePromptTemplateLike[] = [
|
||||
['system', 'Test'],
|
||||
['human', 'Hello'],
|
||||
];
|
||||
const prompt = preparePrompt(sampleMessages);
|
||||
|
||||
expect(prompt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentStepsParser', () => {
|
||||
let mockMemory: BaseChatMemory;
|
||||
|
||||
beforeEach(() => {
|
||||
mockMemory = mock<BaseChatMemory>();
|
||||
});
|
||||
|
||||
describe('with format_final_json_response tool', () => {
|
||||
it('should parse output from format_final_json_response tool', async () => {
|
||||
const steps: AgentAction[] = [
|
||||
{
|
||||
tool: 'format_final_json_response',
|
||||
toolInput: { city: 'Berlin', temperature: 15 },
|
||||
log: '',
|
||||
},
|
||||
];
|
||||
|
||||
const mockOutputParser = createMockOutputParser({
|
||||
city: 'Berlin',
|
||||
temperature: 15,
|
||||
});
|
||||
|
||||
const parser = getAgentStepsParser(mockOutputParser, mockMemory);
|
||||
const result = await parser(steps);
|
||||
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith('{"city":"Berlin","temperature":15}');
|
||||
expect(result).toEqual({
|
||||
returnValues: { output: '{"city":"Berlin","temperature":15}' },
|
||||
log: 'Final response formatted',
|
||||
});
|
||||
});
|
||||
|
||||
it('should stringify tool input if it is not an object', async () => {
|
||||
const steps: AgentAction[] = [
|
||||
{
|
||||
tool: 'format_final_json_response',
|
||||
toolInput: 'simple string',
|
||||
log: '',
|
||||
},
|
||||
];
|
||||
|
||||
const mockOutputParser = createMockOutputParser({ text: 'simple string' });
|
||||
|
||||
const parser = getAgentStepsParser(mockOutputParser, mockMemory);
|
||||
const result = await parser(steps);
|
||||
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith('simple string');
|
||||
expect(result).toEqual({
|
||||
returnValues: { output: '{"text":"simple string"}' },
|
||||
log: 'Final response formatted',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('manual parsing path', () => {
|
||||
it('should handle already wrapped output structure correctly', async () => {
|
||||
// Agent returns output that already has { output: {...} } structure
|
||||
const steps: AgentFinish = {
|
||||
returnValues: {
|
||||
output: '{"output":{"city":"Berlin","temperature":15}}',
|
||||
},
|
||||
log: '',
|
||||
};
|
||||
|
||||
const mockOutputParser = createMockOutputParser({
|
||||
city: 'Berlin',
|
||||
temperature: 15,
|
||||
});
|
||||
|
||||
const parser = getAgentStepsParser(mockOutputParser, mockMemory);
|
||||
const result = await parser(steps);
|
||||
|
||||
// Should detect the existing wrapper and not double-wrap
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith(
|
||||
'{"output":{"city":"Berlin","temperature":15}}',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
returnValues: { output: '{"city":"Berlin","temperature":15}' },
|
||||
log: 'Final response formatted',
|
||||
});
|
||||
});
|
||||
|
||||
it('should wrap output that is not already wrapped', async () => {
|
||||
// Agent returns plain data without { output: ... } wrapper
|
||||
const steps: AgentFinish = {
|
||||
returnValues: {
|
||||
output: '{"city":"Berlin","temperature":15}',
|
||||
},
|
||||
log: '',
|
||||
};
|
||||
|
||||
const mockOutputParser = createMockOutputParser({
|
||||
city: 'Berlin',
|
||||
temperature: 15,
|
||||
});
|
||||
|
||||
const parser = getAgentStepsParser(mockOutputParser, mockMemory);
|
||||
const result = await parser(steps);
|
||||
|
||||
// Should wrap the data in { output: ... } for the parser
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith(
|
||||
'{"output":{"city":"Berlin","temperature":15}}',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
returnValues: { output: '{"city":"Berlin","temperature":15}' },
|
||||
log: 'Final response formatted',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle output with additional properties correctly', async () => {
|
||||
// Output has more than just the "output" property
|
||||
const steps: AgentFinish = {
|
||||
returnValues: {
|
||||
output: '{"output":{"text":"Hello"},"metadata":{"source":"test"}}',
|
||||
},
|
||||
log: '',
|
||||
};
|
||||
|
||||
const mockOutputParser = createMockOutputParser({
|
||||
text: 'Hello',
|
||||
metadata: { source: 'test' },
|
||||
});
|
||||
|
||||
const parser = getAgentStepsParser(mockOutputParser, mockMemory);
|
||||
const result = await parser(steps);
|
||||
|
||||
// Should wrap since it has multiple properties
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith(
|
||||
'{"output":{"output":{"text":"Hello"},"metadata":{"source":"test"}}}',
|
||||
);
|
||||
expect(result).toEqual({
|
||||
returnValues: { output: '{"text":"Hello","metadata":{"source":"test"}}' },
|
||||
log: 'Final response formatted',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle parse errors gracefully', async () => {
|
||||
const steps: AgentFinish = {
|
||||
returnValues: {
|
||||
output: 'invalid json',
|
||||
},
|
||||
log: '',
|
||||
};
|
||||
|
||||
const mockOutputParser = createMockOutputParser({ text: 'invalid json' });
|
||||
|
||||
const parser = getAgentStepsParser(mockOutputParser, mockMemory);
|
||||
const result = await parser(steps);
|
||||
|
||||
// Should fallback to raw output when JSON parsing fails
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith('invalid json');
|
||||
expect(result).toEqual({
|
||||
returnValues: { output: '{"text":"invalid json"}' },
|
||||
log: 'Final response formatted',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle null output correctly', async () => {
|
||||
const steps: AgentFinish = {
|
||||
returnValues: {
|
||||
output: 'null',
|
||||
},
|
||||
log: '',
|
||||
};
|
||||
|
||||
const mockOutputParser = createMockOutputParser({ result: null });
|
||||
|
||||
const parser = getAgentStepsParser(mockOutputParser, mockMemory);
|
||||
const result = await parser(steps);
|
||||
|
||||
// Should wrap null in { output: null }
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith('{"output":null}');
|
||||
expect(result).toEqual({
|
||||
returnValues: { output: '{"result":null}' },
|
||||
log: 'Final response formatted',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle undefined-like values correctly', async () => {
|
||||
const steps: AgentFinish = {
|
||||
returnValues: {
|
||||
output: 'undefined',
|
||||
},
|
||||
log: '',
|
||||
};
|
||||
|
||||
const mockOutputParser = createMockOutputParser({ text: 'undefined' });
|
||||
|
||||
const parser = getAgentStepsParser(mockOutputParser, mockMemory);
|
||||
const result = await parser(steps);
|
||||
|
||||
// Should fallback to raw string since "undefined" is not valid JSON
|
||||
expect(mockOutputParser.parse).toHaveBeenCalledWith('undefined');
|
||||
expect(result).toEqual({
|
||||
returnValues: { output: '{"text":"undefined"}' },
|
||||
log: 'Final response formatted',
|
||||
});
|
||||
});
|
||||
|
||||
it('should return output as-is without memory', async () => {
|
||||
const steps: AgentFinish = {
|
||||
returnValues: {
|
||||
output: '{"city":"Berlin","temperature":15}',
|
||||
},
|
||||
log: '',
|
||||
};
|
||||
|
||||
const mockOutputParser = createMockOutputParser({
|
||||
city: 'Berlin',
|
||||
temperature: 15,
|
||||
});
|
||||
|
||||
const parser = getAgentStepsParser(mockOutputParser, undefined);
|
||||
const result = await parser(steps);
|
||||
|
||||
expect(result).toEqual({
|
||||
returnValues: { city: 'Berlin', temperature: 15 },
|
||||
log: 'Final response formatted',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('without output parser', () => {
|
||||
it('should pass through agent finish steps unchanged', async () => {
|
||||
const steps: AgentFinish = {
|
||||
returnValues: { output: 'Final answer' },
|
||||
log: '',
|
||||
};
|
||||
|
||||
const parser = getAgentStepsParser(undefined, undefined);
|
||||
const result = await parser(steps);
|
||||
|
||||
expect(result).toEqual({
|
||||
log: '',
|
||||
returnValues: { output: 'Final answer' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle array of agent actions', async () => {
|
||||
const steps: AgentAction[] = [
|
||||
{ tool: 'some_tool', toolInput: { query: 'test' }, log: '' },
|
||||
{ tool: 'another_tool', toolInput: { data: 'value' }, log: '' },
|
||||
];
|
||||
|
||||
const parser = getAgentStepsParser(undefined, undefined);
|
||||
const result = await parser(steps);
|
||||
|
||||
expect(result).toEqual(steps);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('handleAgentFinishOutput', () => {
|
||||
it('should merge multi-output text arrays into a single string', () => {
|
||||
const steps: AgentFinish = {
|
||||
returnValues: {
|
||||
output: [
|
||||
{ index: 0, type: 'text', text: 'First part' },
|
||||
{ index: 1, type: 'text', text: 'Second part' },
|
||||
],
|
||||
},
|
||||
log: '',
|
||||
};
|
||||
|
||||
const result = handleAgentFinishOutput(steps);
|
||||
|
||||
expect(result).toEqual({
|
||||
log: '',
|
||||
returnValues: {
|
||||
output: 'First part\nSecond part',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('should not modify non-text multi-output arrays', () => {
|
||||
const steps: AgentFinish = {
|
||||
returnValues: {
|
||||
output: [
|
||||
{ index: 0, type: 'text', text: 'Text part' },
|
||||
{ index: 1, type: 'image', url: 'http://example.com/image.png' },
|
||||
],
|
||||
},
|
||||
log: '',
|
||||
};
|
||||
|
||||
const result = handleAgentFinishOutput(steps);
|
||||
|
||||
expect(result).toEqual(steps);
|
||||
});
|
||||
|
||||
it('should not modify simple string output', () => {
|
||||
const steps: AgentFinish = {
|
||||
returnValues: {
|
||||
output: 'Simple string output',
|
||||
},
|
||||
log: '',
|
||||
};
|
||||
|
||||
const result = handleAgentFinishOutput(steps);
|
||||
|
||||
expect(result).toEqual(steps);
|
||||
});
|
||||
|
||||
it('should handle agent action arrays unchanged', () => {
|
||||
const steps: AgentAction[] = [
|
||||
{
|
||||
tool: 'tool1',
|
||||
toolInput: {},
|
||||
log: '',
|
||||
},
|
||||
{
|
||||
tool: 'tool2',
|
||||
toolInput: {},
|
||||
log: '',
|
||||
},
|
||||
];
|
||||
|
||||
const result = handleAgentFinishOutput(steps);
|
||||
|
||||
expect(result).toEqual(steps);
|
||||
});
|
||||
|
||||
it('should filter out thinking blocks and return only text blocks', () => {
|
||||
const steps: AgentFinish = {
|
||||
returnValues: {
|
||||
output: [
|
||||
{ index: 0, type: 'thinking', thinking: 'Internal reasoning...' },
|
||||
{ index: 1, type: 'text', text: 'User-facing output' },
|
||||
],
|
||||
},
|
||||
log: '',
|
||||
};
|
||||
|
||||
const result = handleAgentFinishOutput(steps) as AgentFinish;
|
||||
|
||||
expect(result.returnValues.output).toBe('User-facing output');
|
||||
});
|
||||
|
||||
it('should return thinking content when no text blocks exist', () => {
|
||||
const steps: AgentFinish = {
|
||||
returnValues: {
|
||||
output: [
|
||||
{ index: 0, type: 'thinking', thinking: 'Only thinking content' },
|
||||
{ index: 1, type: 'thinking', thinking: 'More thinking' },
|
||||
],
|
||||
},
|
||||
log: '',
|
||||
};
|
||||
|
||||
const result = handleAgentFinishOutput(steps) as AgentFinish;
|
||||
|
||||
expect(result.returnValues.output).toBe('Only thinking content\nMore thinking');
|
||||
});
|
||||
|
||||
it('should return empty string when no text or thinking blocks exist', () => {
|
||||
const steps: AgentFinish = {
|
||||
returnValues: {
|
||||
output: [{ index: 0, type: 'unknown' }],
|
||||
},
|
||||
log: '',
|
||||
};
|
||||
|
||||
const result = handleAgentFinishOutput(steps) as AgentFinish;
|
||||
|
||||
expect(result.returnValues.output).toBe('');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user