mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
feat: AI Workflow Builder agent (no-changelog) (#17423)
Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,707 @@
|
||||
import type { BaseMessage } from '@langchain/core/messages';
|
||||
import { AIMessage, HumanMessage, ToolMessage } from '@langchain/core/messages';
|
||||
import type { DynamicStructuredTool } from '@langchain/core/tools';
|
||||
import { ToolInputParsingException } from '@langchain/core/tools';
|
||||
import type { Command as CommandType } from '@langchain/langgraph';
|
||||
|
||||
import { createWorkflow, createNode } from '../../../test/test-utils';
|
||||
import type { ToolExecutorOptions } from '../../types/config';
|
||||
import type { WorkflowOperation } from '../../types/workflow';
|
||||
import type { WorkflowState } from '../../workflow-state';
|
||||
import { executeToolsInParallel } from '../tool-executor';
|
||||
|
||||
// Type for our mocked Command
|
||||
type MockedCommand = CommandType & { _isCommand: boolean };
|
||||
|
||||
// Mock LangGraph dependencies
|
||||
jest.mock('@langchain/langgraph', () => {
|
||||
// Mock Command class
|
||||
class MockCommand {
|
||||
_isCommand = true;
|
||||
update: unknown;
|
||||
|
||||
constructor(params: { update: unknown }) {
|
||||
this.update = params.update;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
isCommand: jest.fn((obj: unknown) => {
|
||||
return (
|
||||
obj instanceof MockCommand || (obj && (obj as { _isCommand?: boolean })._isCommand === true)
|
||||
);
|
||||
}),
|
||||
Command: MockCommand,
|
||||
};
|
||||
});
|
||||
|
||||
// Get properly typed Command from mock
|
||||
const MockCommand = jest.requireMock<{
|
||||
Command: new (params: { update: unknown }) => MockedCommand;
|
||||
}>('@langchain/langgraph').Command;
|
||||
|
||||
describe('tool-executor', () => {
|
||||
describe('executeToolsInParallel', () => {
|
||||
// Helper to create mock state
|
||||
const createState = (messages: BaseMessage[]): typeof WorkflowState.State => ({
|
||||
workflowJSON: createWorkflow([]),
|
||||
workflowOperations: null,
|
||||
messages,
|
||||
workflowContext: {},
|
||||
});
|
||||
|
||||
// Helper to create mock tool
|
||||
const createMockTool = (result: unknown) =>
|
||||
({
|
||||
invoke: jest.fn().mockResolvedValue(result),
|
||||
name: 'mock-tool',
|
||||
description: 'Mock tool',
|
||||
schema: {},
|
||||
func: jest.fn(),
|
||||
}) as unknown as DynamicStructuredTool;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should execute single tool successfully', async () => {
|
||||
const toolMessage = new ToolMessage({
|
||||
content: 'Tool executed successfully',
|
||||
tool_call_id: 'call-1',
|
||||
});
|
||||
const mockTool = createMockTool(toolMessage);
|
||||
|
||||
const aiMessage = new AIMessage('');
|
||||
aiMessage.tool_calls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'test_tool',
|
||||
args: { param: 'value' },
|
||||
type: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const state = createState([new HumanMessage('Test'), aiMessage]);
|
||||
const toolMap = new Map<string, DynamicStructuredTool>([['test_tool', mockTool]]);
|
||||
|
||||
const options: ToolExecutorOptions = { state, toolMap };
|
||||
const result = await executeToolsInParallel(options);
|
||||
|
||||
expect(mockTool.invoke).toHaveBeenCalledWith(
|
||||
{ param: 'value' },
|
||||
{
|
||||
toolCall: {
|
||||
id: 'call-1',
|
||||
name: 'test_tool',
|
||||
args: { param: 'value' },
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect(result.messages?.[0]).toBe(toolMessage);
|
||||
expect(result.workflowOperations).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should execute multiple tools in parallel', async () => {
|
||||
const toolMessage1 = new ToolMessage({
|
||||
content: 'Tool 1 result',
|
||||
tool_call_id: 'call-1',
|
||||
});
|
||||
const toolMessage2 = new ToolMessage({
|
||||
content: 'Tool 2 result',
|
||||
tool_call_id: 'call-2',
|
||||
});
|
||||
|
||||
const mockTool1 = createMockTool(toolMessage1);
|
||||
const mockTool2 = createMockTool(toolMessage2);
|
||||
|
||||
const aiMessage = new AIMessage('');
|
||||
aiMessage.tool_calls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'tool1',
|
||||
args: { param: 'value1' },
|
||||
type: 'tool_call',
|
||||
},
|
||||
{
|
||||
id: 'call-2',
|
||||
name: 'tool2',
|
||||
args: { param: 'value2' },
|
||||
type: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const state = createState([aiMessage]);
|
||||
const toolMap = new Map<string, DynamicStructuredTool>([
|
||||
['tool1', mockTool1],
|
||||
['tool2', mockTool2],
|
||||
]);
|
||||
|
||||
const options: ToolExecutorOptions = { state, toolMap };
|
||||
const result = await executeToolsInParallel(options);
|
||||
|
||||
expect(mockTool1.invoke).toHaveBeenCalled();
|
||||
expect(mockTool2.invoke).toHaveBeenCalled();
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(result.messages).toContain(toolMessage1);
|
||||
expect(result.messages).toContain(toolMessage2);
|
||||
});
|
||||
|
||||
it('should handle tool returning Command with state updates', async () => {
|
||||
const operations: WorkflowOperation[] = [
|
||||
{ type: 'addNodes', nodes: [createNode({ id: 'node1' })] },
|
||||
];
|
||||
const stateUpdate: Partial<typeof WorkflowState.State> = {
|
||||
workflowOperations: operations,
|
||||
messages: [new ToolMessage({ content: 'Added node', tool_call_id: 'call-1' })],
|
||||
};
|
||||
|
||||
const command = new MockCommand({ update: stateUpdate });
|
||||
const mockTool = createMockTool(command);
|
||||
|
||||
const aiMessage = new AIMessage('');
|
||||
aiMessage.tool_calls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'add_nodes',
|
||||
args: { nodeType: 'n8n-nodes-base.code' },
|
||||
type: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const state = createState([aiMessage]);
|
||||
const toolMap = new Map<string, DynamicStructuredTool>([['add_nodes', mockTool]]);
|
||||
|
||||
const options: ToolExecutorOptions = { state, toolMap };
|
||||
const result = await executeToolsInParallel(options);
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
expect(result.workflowOperations).toEqual(operations);
|
||||
});
|
||||
|
||||
it('should handle tool returning regular messages', async () => {
|
||||
const toolMessage = new ToolMessage({
|
||||
content: 'Regular message',
|
||||
tool_call_id: 'call-1',
|
||||
});
|
||||
const mockTool = createMockTool(toolMessage);
|
||||
|
||||
const aiMessage = new AIMessage('');
|
||||
aiMessage.tool_calls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'test_tool',
|
||||
args: {},
|
||||
type: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const state = createState([aiMessage]);
|
||||
const toolMap = new Map<string, DynamicStructuredTool>([['test_tool', mockTool]]);
|
||||
|
||||
const options: ToolExecutorOptions = { state, toolMap };
|
||||
const result = await executeToolsInParallel(options);
|
||||
|
||||
expect(result.messages).toEqual([toolMessage]);
|
||||
expect(result.workflowOperations).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should collect all workflow operations from multiple tools', async () => {
|
||||
const operations1: WorkflowOperation[] = [
|
||||
{ type: 'addNodes', nodes: [createNode({ id: 'node1' })] },
|
||||
];
|
||||
const operations2: WorkflowOperation[] = [{ type: 'setConnections', connections: {} }];
|
||||
|
||||
const command1 = new MockCommand({
|
||||
update: {
|
||||
workflowOperations: operations1,
|
||||
messages: [],
|
||||
},
|
||||
});
|
||||
const command2 = new MockCommand({
|
||||
update: {
|
||||
workflowOperations: operations2,
|
||||
messages: [],
|
||||
},
|
||||
});
|
||||
|
||||
const mockTool1 = createMockTool(command1);
|
||||
const mockTool2 = createMockTool(command2);
|
||||
|
||||
const aiMessage = new AIMessage('');
|
||||
aiMessage.tool_calls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'tool1',
|
||||
args: {},
|
||||
type: 'tool_call',
|
||||
},
|
||||
{
|
||||
id: 'call-2',
|
||||
name: 'tool2',
|
||||
args: {},
|
||||
type: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const state = createState([aiMessage]);
|
||||
const toolMap = new Map<string, DynamicStructuredTool>([
|
||||
['tool1', mockTool1],
|
||||
['tool2', mockTool2],
|
||||
]);
|
||||
|
||||
const options: ToolExecutorOptions = { state, toolMap };
|
||||
const result = await executeToolsInParallel(options);
|
||||
|
||||
expect(result.workflowOperations).toHaveLength(2);
|
||||
expect(result.workflowOperations).toEqual([...operations1, ...operations2]);
|
||||
});
|
||||
|
||||
it('should merge messages from both direct returns and state updates', async () => {
|
||||
const directMessage = new ToolMessage({
|
||||
content: 'Direct message',
|
||||
tool_call_id: 'call-1',
|
||||
});
|
||||
const stateMessage = new ToolMessage({
|
||||
content: 'State message',
|
||||
tool_call_id: 'call-2',
|
||||
});
|
||||
|
||||
const command = new MockCommand({
|
||||
update: {
|
||||
messages: [stateMessage],
|
||||
workflowOperations: [],
|
||||
},
|
||||
});
|
||||
|
||||
const mockTool1 = createMockTool(directMessage);
|
||||
const mockTool2 = createMockTool(command);
|
||||
|
||||
const aiMessage = new AIMessage('');
|
||||
aiMessage.tool_calls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'tool1',
|
||||
args: {},
|
||||
type: 'tool_call',
|
||||
},
|
||||
{
|
||||
id: 'call-2',
|
||||
name: 'tool2',
|
||||
args: {},
|
||||
type: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const state = createState([aiMessage]);
|
||||
const toolMap = new Map<string, DynamicStructuredTool>([
|
||||
['tool1', mockTool1],
|
||||
['tool2', mockTool2],
|
||||
]);
|
||||
|
||||
const options: ToolExecutorOptions = { state, toolMap };
|
||||
const result = await executeToolsInParallel(options);
|
||||
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(result.messages).toContain(directMessage);
|
||||
expect(result.messages).toContain(stateMessage);
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should throw when last message is not AIMessage', async () => {
|
||||
const state = createState([new HumanMessage('Test')]);
|
||||
const toolMap = new Map<string, DynamicStructuredTool>();
|
||||
|
||||
const options: ToolExecutorOptions = { state, toolMap };
|
||||
|
||||
await expect(executeToolsInParallel(options)).rejects.toThrow(
|
||||
'Most recent message must be an AIMessage with tool calls',
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw when AIMessage has no tool_calls', async () => {
|
||||
const aiMessage = new AIMessage('No tool calls');
|
||||
const state = createState([aiMessage]);
|
||||
const toolMap = new Map<string, DynamicStructuredTool>();
|
||||
|
||||
const options: ToolExecutorOptions = { state, toolMap };
|
||||
|
||||
await expect(executeToolsInParallel(options)).rejects.toThrow(
|
||||
'AIMessage must have tool calls',
|
||||
);
|
||||
});
|
||||
|
||||
it('should return error message when tool is not found in toolMap', async () => {
|
||||
const aiMessage = new AIMessage('');
|
||||
aiMessage.tool_calls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'non_existent_tool',
|
||||
args: {},
|
||||
type: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const state = createState([aiMessage]);
|
||||
const toolMap = new Map<string, DynamicStructuredTool>(); // Empty map
|
||||
|
||||
const options: ToolExecutorOptions = { state, toolMap };
|
||||
|
||||
const result = await executeToolsInParallel(options);
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
const message = result.messages![0] as ToolMessage;
|
||||
expect(message).toBeInstanceOf(ToolMessage);
|
||||
expect(message.content).toBe(
|
||||
'Tool non_existent_tool failed: Tool non_existent_tool not found',
|
||||
);
|
||||
expect(message.tool_call_id).toBe('call-1');
|
||||
expect(message.additional_kwargs.error).toBe(true);
|
||||
});
|
||||
|
||||
it('should wrap schema validation errors as ValidationError', async () => {
|
||||
const mockTool = createMockTool(null);
|
||||
// Mock tool throwing a ToolInputParsingException
|
||||
mockTool.invoke = jest
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new ToolInputParsingException('Received tool input did not match expected schema'),
|
||||
);
|
||||
|
||||
const aiMessage = new AIMessage('');
|
||||
aiMessage.tool_calls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'test_tool',
|
||||
args: { invalidParam: 'value' },
|
||||
type: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const state = createState([aiMessage]);
|
||||
const toolMap = new Map<string, DynamicStructuredTool>([['test_tool', mockTool]]);
|
||||
|
||||
const options: ToolExecutorOptions = { state, toolMap };
|
||||
|
||||
const result = await executeToolsInParallel(options);
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
const message = result.messages![0] as ToolMessage;
|
||||
expect(message).toBeInstanceOf(ToolMessage);
|
||||
expect(message.content).toBe(
|
||||
'Invalid input for tool test_tool: Received tool input did not match expected schema',
|
||||
);
|
||||
expect(message.tool_call_id).toBe('call-1');
|
||||
expect(message.additional_kwargs.error).toBe(true);
|
||||
});
|
||||
|
||||
it('should wrap schema validation errors with "expected schema" message as ValidationError', async () => {
|
||||
const mockTool = createMockTool(null);
|
||||
// Mock tool throwing a regular Error with schema validation message
|
||||
mockTool.invoke = jest
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Tool input validation failed: expected schema'));
|
||||
|
||||
const aiMessage = new AIMessage('');
|
||||
aiMessage.tool_calls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'update_params',
|
||||
args: { wrongField: 123 },
|
||||
type: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const state = createState([aiMessage]);
|
||||
const toolMap = new Map<string, DynamicStructuredTool>([['update_params', mockTool]]);
|
||||
|
||||
const options: ToolExecutorOptions = { state, toolMap };
|
||||
|
||||
const result = await executeToolsInParallel(options);
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
const message = result.messages![0] as ToolMessage;
|
||||
expect(message).toBeInstanceOf(ToolMessage);
|
||||
expect(message.content).toBe(
|
||||
'Invalid input for tool update_params: Tool input validation failed: expected schema',
|
||||
);
|
||||
expect(message.tool_call_id).toBe('call-1');
|
||||
expect(message.additional_kwargs.error).toBe(true);
|
||||
});
|
||||
|
||||
it('should wrap other tool errors as ToolExecutionError', async () => {
|
||||
const mockTool = createMockTool(null);
|
||||
// Mock tool throwing a generic error
|
||||
mockTool.invoke = jest.fn().mockRejectedValue(new Error('Connection timeout'));
|
||||
|
||||
const aiMessage = new AIMessage('');
|
||||
aiMessage.tool_calls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'http_request',
|
||||
args: { url: 'https://example.com' },
|
||||
type: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const state = createState([aiMessage]);
|
||||
const toolMap = new Map<string, DynamicStructuredTool>([['http_request', mockTool]]);
|
||||
|
||||
const options: ToolExecutorOptions = { state, toolMap };
|
||||
|
||||
const result = await executeToolsInParallel(options);
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
const message = result.messages![0] as ToolMessage;
|
||||
expect(message).toBeInstanceOf(ToolMessage);
|
||||
expect(message.content).toBe('Tool http_request failed: Connection timeout');
|
||||
expect(message.tool_call_id).toBe('call-1');
|
||||
expect(message.additional_kwargs.error).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle non-Error objects thrown by tools', async () => {
|
||||
const mockTool = createMockTool(null);
|
||||
// Mock tool throwing a non-Error object
|
||||
mockTool.invoke = jest.fn().mockRejectedValue('String error');
|
||||
|
||||
const aiMessage = new AIMessage('');
|
||||
aiMessage.tool_calls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'test_tool',
|
||||
args: {},
|
||||
type: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const state = createState([aiMessage]);
|
||||
const toolMap = new Map<string, DynamicStructuredTool>([['test_tool', mockTool]]);
|
||||
|
||||
const options: ToolExecutorOptions = { state, toolMap };
|
||||
|
||||
const result = await executeToolsInParallel(options);
|
||||
|
||||
expect(result.messages).toHaveLength(1);
|
||||
const message = result.messages![0] as ToolMessage;
|
||||
expect(message).toBeInstanceOf(ToolMessage);
|
||||
expect(message.content).toBe('Tool test_tool failed: Unknown error occurred');
|
||||
expect(message.tool_call_id).toBe('call-1');
|
||||
expect(message.additional_kwargs.error).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle multiple tools with mixed success and failure', async () => {
|
||||
const successMessage = new ToolMessage({
|
||||
content: 'Success',
|
||||
tool_call_id: 'call-1',
|
||||
});
|
||||
const mockSuccessTool = createMockTool(successMessage);
|
||||
|
||||
const mockFailureTool = createMockTool(null);
|
||||
mockFailureTool.invoke = jest
|
||||
.fn()
|
||||
.mockRejectedValue(
|
||||
new ToolInputParsingException('Received tool input did not match expected schema'),
|
||||
);
|
||||
|
||||
const aiMessage = new AIMessage('');
|
||||
aiMessage.tool_calls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'success_tool',
|
||||
args: { valid: true },
|
||||
type: 'tool_call',
|
||||
},
|
||||
{
|
||||
id: 'call-2',
|
||||
name: 'failure_tool',
|
||||
args: { invalid: true },
|
||||
type: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const state = createState([aiMessage]);
|
||||
const toolMap = new Map<string, DynamicStructuredTool>([
|
||||
['success_tool', mockSuccessTool],
|
||||
['failure_tool', mockFailureTool],
|
||||
]);
|
||||
|
||||
const options: ToolExecutorOptions = { state, toolMap };
|
||||
|
||||
// Both tools run in parallel, one succeeds and one returns error message
|
||||
const result = await executeToolsInParallel(options);
|
||||
|
||||
expect(result.messages).toHaveLength(2);
|
||||
|
||||
// First message should be the success
|
||||
const successMsg = result.messages![0] as ToolMessage;
|
||||
expect(successMsg).toBeInstanceOf(ToolMessage);
|
||||
expect(successMsg.content).toBe('Success');
|
||||
expect(successMsg.tool_call_id).toBe('call-1');
|
||||
|
||||
// Second message should be the error
|
||||
const errorMsg = result.messages![1] as ToolMessage;
|
||||
expect(errorMsg).toBeInstanceOf(ToolMessage);
|
||||
expect(errorMsg.content).toBe(
|
||||
'Invalid input for tool failure_tool: Received tool input did not match expected schema',
|
||||
);
|
||||
expect(errorMsg.tool_call_id).toBe('call-2');
|
||||
expect(errorMsg.additional_kwargs.error).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle tools with no operations (only messages)', async () => {
|
||||
const message = new ToolMessage({
|
||||
content: 'Message only',
|
||||
tool_call_id: 'call-1',
|
||||
});
|
||||
|
||||
const command = new MockCommand({
|
||||
update: {
|
||||
messages: [message],
|
||||
// No workflowOperations
|
||||
},
|
||||
});
|
||||
|
||||
const mockTool = createMockTool(command);
|
||||
|
||||
const aiMessage = new AIMessage('');
|
||||
aiMessage.tool_calls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'test_tool',
|
||||
args: {},
|
||||
type: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const state = createState([aiMessage]);
|
||||
const toolMap = new Map<string, DynamicStructuredTool>([['test_tool', mockTool]]);
|
||||
|
||||
const options: ToolExecutorOptions = { state, toolMap };
|
||||
const result = await executeToolsInParallel(options);
|
||||
|
||||
expect(result.messages).toEqual([message]);
|
||||
expect(result.workflowOperations).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should handle tools with no messages (only operations)', async () => {
|
||||
const operations: WorkflowOperation[] = [{ type: 'clear' }];
|
||||
|
||||
const command = new MockCommand({
|
||||
update: {
|
||||
workflowOperations: operations,
|
||||
// No messages
|
||||
},
|
||||
});
|
||||
|
||||
const mockTool = createMockTool(command);
|
||||
|
||||
const aiMessage = new AIMessage('');
|
||||
aiMessage.tool_calls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'clear_tool',
|
||||
args: {},
|
||||
type: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const state = createState([aiMessage]);
|
||||
const toolMap = new Map<string, DynamicStructuredTool>([['clear_tool', mockTool]]);
|
||||
|
||||
const options: ToolExecutorOptions = { state, toolMap };
|
||||
const result = await executeToolsInParallel(options);
|
||||
|
||||
expect(result.messages).toEqual([]);
|
||||
expect(result.workflowOperations).toEqual(operations);
|
||||
});
|
||||
|
||||
it('should handle empty tool_calls array', async () => {
|
||||
const aiMessage = new AIMessage('');
|
||||
aiMessage.tool_calls = []; // Empty array
|
||||
|
||||
const state = createState([aiMessage]);
|
||||
const toolMap = new Map<string, DynamicStructuredTool>();
|
||||
|
||||
const options: ToolExecutorOptions = { state, toolMap };
|
||||
|
||||
await expect(executeToolsInParallel(options)).rejects.toThrow(
|
||||
'AIMessage must have tool calls',
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle tool calls without args', async () => {
|
||||
const toolMessage = new ToolMessage({
|
||||
content: 'Success',
|
||||
tool_call_id: 'call-1',
|
||||
});
|
||||
const mockTool = createMockTool(toolMessage);
|
||||
|
||||
const aiMessage = new AIMessage('');
|
||||
aiMessage.tool_calls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'test_tool',
|
||||
args: {}, // No args
|
||||
type: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const state = createState([aiMessage]);
|
||||
const toolMap = new Map<string, DynamicStructuredTool>([['test_tool', mockTool]]);
|
||||
|
||||
const options: ToolExecutorOptions = { state, toolMap };
|
||||
const result = await executeToolsInParallel(options);
|
||||
|
||||
expect(mockTool.invoke).toHaveBeenCalledWith(
|
||||
{}, // Empty object when args is undefined
|
||||
{
|
||||
toolCall: {
|
||||
id: 'call-1',
|
||||
name: 'test_tool',
|
||||
args: {},
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result.messages).toEqual([toolMessage]);
|
||||
});
|
||||
|
||||
it('should handle multiple state updates with various message types', async () => {
|
||||
const aiResultMessage = new AIMessage('Result from tool');
|
||||
const toolResultMessage = new ToolMessage({
|
||||
content: 'Tool result',
|
||||
tool_call_id: 'call-1',
|
||||
});
|
||||
|
||||
const command = new MockCommand({
|
||||
update: {
|
||||
messages: [aiResultMessage, toolResultMessage],
|
||||
workflowOperations: [{ type: 'clear' }],
|
||||
},
|
||||
});
|
||||
|
||||
const mockTool = createMockTool(command);
|
||||
|
||||
const aiMessage = new AIMessage('');
|
||||
aiMessage.tool_calls = [
|
||||
{
|
||||
id: 'call-1',
|
||||
name: 'test_tool',
|
||||
args: {},
|
||||
type: 'tool_call',
|
||||
},
|
||||
];
|
||||
|
||||
const state = createState([aiMessage]);
|
||||
const toolMap = new Map<string, DynamicStructuredTool>([['test_tool', mockTool]]);
|
||||
|
||||
const options: ToolExecutorOptions = { state, toolMap };
|
||||
const result = await executeToolsInParallel(options);
|
||||
|
||||
expect(result.messages).toHaveLength(2);
|
||||
expect(result.messages).toContain(aiResultMessage);
|
||||
expect(result.messages).toContain(toolResultMessage);
|
||||
expect(result.workflowOperations).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user