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

This commit is contained in:
2026-03-17 16:22:57 +03:30
commit 3d5eaf9445
15349 changed files with 2847338 additions and 0 deletions
@@ -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');
});
});
@@ -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' } });
});
});
@@ -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('');
});
});