import { AgentExecutor } from '@langchain/classic/agents';
import type { Tool } from '@langchain/classic/tools';
import type { BaseChatModel } from '@langchain/core/language_models/chat_models';
import type { ISupplyDataFunctions, IExecuteFunctions, INode } from 'n8n-workflow';
import type { Mock } from 'vitest';
import { mock } from 'vitest-mock-extended';

import * as helpers from '../../../../../utils/helpers';
import * as outputParserModule from '../../../../../utils/output_parsers/N8nOutputParser';
import * as tracing from '../../../../../utils/tracing';
import * as commonModule from '../../agents/ToolsAgent/common';
import { toolsAgentExecute } from '../../agents/ToolsAgent/V2/execute';

vi.mock('../../../../../utils/output_parsers/N8nOutputParser', () => ({
	getOptionalOutputParser: vi.fn(),
	N8nStructuredOutputParser: vi.fn(),
}));

vi.mock('../../agents/ToolsAgent/common', async () => ({
	...(await vi.importActual('../../agents/ToolsAgent/common')),
	getOptionalMemory: vi.fn(),
}));

const mockHelpers = mock<IExecuteFunctions['helpers']>();
const mockContext = mock<IExecuteFunctions>({ helpers: mockHelpers });
const ensureWithConfig = <T extends object>(executor: T) => {
	(executor as { withConfig: Mock }).withConfig = vi.fn().mockReturnValue(executor);
	return executor;
};

beforeEach(() => {
	vi.clearAllMocks();
	vi.resetAllMocks();
});

describe('toolsAgentExecute', () => {
	beforeEach(() => {
		vi.clearAllMocks();
		mockContext.logger = {
			debug: vi.fn(),
			info: vi.fn(),
			warn: vi.fn(),
			error: vi.fn(),
		};
		mockContext.getWorkflow.mockReturnValue({ name: 'Test Workflow' } as any);
		mockContext.getExecutionId.mockReturnValue('exec-123');
		mockContext.getExecuteData.mockReturnValue({} as any);
	});

	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 = vi.fn();
		mockModel.lc_namespace = ['chat_models'];
		mockContext.getInputConnectionData.mockResolvedValue(mockModel);

		const mockTools = [mock<Tool>()];
		vi.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: vi
				.fn()
				.mockResolvedValueOnce({ output: { text: 'success 1' } })
				.mockResolvedValueOnce({ output: { text: 'success 2' } }),
		};

		vi.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(
			ensureWithConfig(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 report tool_calls.total from completed tool runs even when returnIntermediateSteps is false', async () => {
		const mockNode = mock<INode>();
		mockNode.typeVersion = 2;
		mockContext.getNode.mockReturnValue(mockNode);
		mockContext.getInputData.mockReturnValue([{ json: { text: 'test input' } }]);

		const mockModel = mock<BaseChatModel>();
		mockModel.bindTools = vi.fn();
		mockModel.lc_namespace = ['chat_models'];
		mockContext.getInputConnectionData.mockResolvedValue(mockModel);

		vi.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);

		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 {
					returnIntermediateSteps: false,
					passthroughBinaryImages: true,
				};
			return defaultValue;
		});

		// Simulate two completed tool calls by firing the tool-end callback twice.
		const mockExecutor = {
			invoke: vi.fn().mockImplementation(async (_invokeParams, executeOptions) => {
				const callbacks = (executeOptions?.callbacks ?? []) as Array<{
					handleToolEnd?: () => void;
				}>;
				for (const cb of callbacks) {
					cb.handleToolEnd?.();
					cb.handleToolEnd?.();
				}
				return { output: 'final answer' };
			}),
		};

		vi.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(
			ensureWithConfig(mockExecutor) as any,
		);

		await toolsAgentExecute.call(mockContext);

		expect(mockContext.setMetadata).toHaveBeenCalledWith({
			tracing: expect.objectContaining({
				'ai.agent.version': 'v2',
				'ai.agent.tool_calls.total': 2,
				'ai.agent.execution.succeeded': true,
			}),
		});
	});

	it('should pass tracing metadata to tracing config', async () => {
		const mockNode = mock<INode>();
		mockNode.typeVersion = 2;
		mockContext.getNode.mockReturnValue(mockNode);
		mockContext.getInputData.mockReturnValue([{ json: { text: 'test input 1' } }]);

		const mockModel = mock<BaseChatModel>();
		mockModel.bindTools = vi.fn();
		mockModel.lc_namespace = ['chat_models'];
		mockContext.getInputConnectionData.mockResolvedValue(mockModel);

		const mockTools = [mock<Tool>()];
		vi.spyOn(helpers, 'getConnectedTools').mockResolvedValue(mockTools);

		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,
					tracingMetadata: {
						values: [{ key: 'team', value: 'ai' }],
					},
				};
			return defaultValue;
		});

		const mockTracingConfig = {
			runName: '[Test Workflow] Test Node',
			metadata: { execution_id: 'test-123', workflow: {}, node: 'Test Node' },
		};
		const tracingSpy = vi.spyOn(tracing, 'getTracingConfig').mockReturnValue(mockTracingConfig);

		const mockExecutor = {
			invoke: vi.fn().mockResolvedValueOnce({ output: { text: 'success' } }),
		};

		vi.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(
			ensureWithConfig(mockExecutor) as any,
		);

		await toolsAgentExecute.call(mockContext);

		expect(tracingSpy).toHaveBeenCalledWith(mockContext, {
			additionalMetadata: { team: 'ai' },
		});
	});

	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 = vi.fn();
		mockModel.lc_namespace = ['chat_models'];
		mockContext.getInputConnectionData.mockResolvedValue(mockModel);

		const mockTools = [mock<Tool>()];
		vi.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: vi
				.fn()
				.mockResolvedValueOnce({ output: { text: 'success 1' } })
				.mockResolvedValueOnce({ output: { text: 'success 2' } })
				.mockResolvedValueOnce({ output: { text: 'success 3' } })
				.mockResolvedValueOnce({ output: { text: 'success 4' } }),
		};

		vi.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(
			ensureWithConfig(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 = vi.fn();
		mockModel.lc_namespace = ['chat_models'];
		mockContext.getInputConnectionData.mockResolvedValue(mockModel);

		const mockTools = [mock<Tool>()];
		vi.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: vi
				.fn()
				.mockResolvedValueOnce({ output: { text: 'success' } })
				.mockRejectedValueOnce(new Error('Test error')),
		};

		vi.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(
			ensureWithConfig(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 = vi.fn();
		mockModel.lc_namespace = ['chat_models'];
		mockContext.getInputConnectionData.mockResolvedValue(mockModel);

		const mockTools = [mock<Tool>()];
		vi.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: vi
				.fn()
				.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success' }) })
				.mockRejectedValueOnce(new Error('Test error')),
		};

		vi.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(
			ensureWithConfig(mockExecutor) as any,
		);

		await expect(toolsAgentExecute.call(mockContext)).rejects.toThrow('Test error');
		expect(mockContext.setMetadata).toHaveBeenCalledWith({
			tracing: expect.objectContaining({
				'ai.agent.version': 'v2',
				'ai.agent.items.failed': 1,
				'ai.agent.execution.succeeded': false,
			}),
		});
	});

	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 = vi.fn();
		mockModel.lc_namespace = ['chat_models'];
		mockContext.getInputConnectionData.mockResolvedValue(mockModel);

		const mockTools = [mock<Tool>()];
		vi.spyOn(helpers, 'getConnectedTools').mockResolvedValue(mockTools);

		const mockParser1 = mock<outputParserModule.N8nStructuredOutputParser>();
		const mockParser2 = mock<outputParserModule.N8nStructuredOutputParser>();
		const mockParser3 = mock<outputParserModule.N8nStructuredOutputParser>();

		const getOptionalOutputParserSpy = vi
			.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: vi
				.fn()
				.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 1' }) })
				.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 2' }) })
				.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 3' }) }),
		};

		vi.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(
			ensureWithConfig(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 = vi.fn();
		mockModel.lc_namespace = ['chat_models'];
		mockContext.getInputConnectionData.mockResolvedValue(mockModel);

		const mockParser1 = mock<outputParserModule.N8nStructuredOutputParser>();
		const mockParser2 = mock<outputParserModule.N8nStructuredOutputParser>();

		vi.spyOn(outputParserModule, 'getOptionalOutputParser')
			.mockResolvedValueOnce(mockParser1)
			.mockResolvedValueOnce(mockParser2);

		const getToolsSpy = vi.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: vi
				.fn()
				.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 1' }) })
				.mockResolvedValueOnce({ output: JSON.stringify({ text: 'success 2' }) }),
		};

		vi.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(
			ensureWithConfig(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 = vi.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 = vi
			.spyOn(outputParserModule, 'getOptionalOutputParser')
			.mockImplementation(async (_ctx, index) => mockParsers[index || 0]);

		vi.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: vi
				.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' }) }),
		};

		vi.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(
			ensureWithConfig(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(() => {
			vi.clearAllMocks();
			mockNode = mock<INode>();
			mockNode.typeVersion = 2.2;
			mockContext.getNode.mockReturnValue(mockNode);
			mockContext.getInputData.mockReturnValue([{ json: { text: 'test input' } }]);

			mockModel = mock<BaseChatModel>();
			mockModel.bindTools = vi.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 () => {
			vi.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
			vi.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: vi.fn().mockReturnValue(mockStreamEvents()),
			};

			vi.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(
				ensureWithConfig(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 () => {
			vi.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
			vi.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: vi.fn().mockReturnValue(mockStreamEvents()),
			};

			vi.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(
				ensureWithConfig(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 () => {
			vi.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
			vi.spyOn(outputParserModule, 'getOptionalOutputParser').mockResolvedValue(undefined);

			const mockExecutor = {
				invoke: vi.fn().mockResolvedValue({ output: 'Regular response' }),
				streamEvents: vi.fn(),
			};

			vi.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(
				ensureWithConfig(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);

			vi.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
			vi.spyOn(outputParserModule, 'getOptionalOutputParser').mockResolvedValue(undefined);

			const mockExecutor = {
				invoke: vi.fn().mockResolvedValue({ output: 'Regular response' }),
				streamEvents: vi.fn(),
			};

			vi.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(
				ensureWithConfig(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: vi.fn().mockResolvedValue({
					chat_history: [
						{ role: 'human', content: 'Message 1' },
						{ role: 'ai', content: 'Response 1' },
					],
				}),
				chatHistory: {
					getMessages: vi.fn().mockResolvedValue([
						{ role: 'human', content: 'Message 1' },
						{ role: 'ai', content: 'Response 1' },
						{ role: 'human', content: 'Message 2' },
						{ role: 'ai', content: 'Response 2' },
					]),
				},
			};

			vi.spyOn(commonModule, 'getOptionalMemory').mockResolvedValue(mockMemory as any);

			vi.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
			vi.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: vi.fn().mockReturnValue(mockStreamEvents()),
			};

			vi.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(
				ensureWithConfig(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 () => {
			vi.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
			vi.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: vi.fn().mockReturnValue(mockStreamEvents()),
			};

			vi.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(
				ensureWithConfig(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 () => {
			vi.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
			vi.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: vi.fn().mockReturnValue(mockStreamEvents()),
			};

			vi.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(
				ensureWithConfig(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 () => {
			vi.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
			vi.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: vi.fn().mockReturnValue(mockStreamEvents()),
			};

			vi.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(
				ensureWithConfig(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 () => {
			vi.spyOn(helpers, 'getConnectedTools').mockResolvedValue([mock<Tool>()]);
			vi.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: vi.fn().mockReturnValue(mockStreamEvents()),
			};

			vi.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(
				ensureWithConfig(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: vi.fn(),
			info: vi.fn(),
			warn: vi.fn(),
			error: vi.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 = vi.fn();
		mockModel.lc_namespace = ['chat_models'];
		mockSupplyDataContext.getInputConnectionData.mockResolvedValue(mockModel);

		const mockTools = [mock<Tool>()];
		vi.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: vi.fn().mockResolvedValueOnce({ output: { text: 'success 1' } }),
		};

		vi.spyOn(AgentExecutor, 'fromAgentAndTools').mockReturnValue(
			ensureWithConfig(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' } });
	});
});
