0d3cb498a3
Deploy local.promptfoo.app / Deploy to Cloudflare Pages (push) Waiting to run
Test and Publish Multi-arch Docker Image / test (push) Waiting to run
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-amd64 platform:linux/amd64 runner:ubuntu-latest]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / build-docker-and-push-digests (map[digest-suffix:linux-arm64 platform:linux/arm64 runner:ubuntu-24.04-arm]) (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / merge-docker-digests (push) Blocked by required conditions
Test and Publish Multi-arch Docker Image / Attest Multi-arch Image (push) Blocked by required conditions
Validate Renovate Config / Validate Renovate Configuration (push) Waiting to run
CI / Shell Format Check (push) Has been cancelled
CI / Check Ruby (3.4) (push) Has been cancelled
CI / CI Config (push) Has been cancelled
CI / Test on Node ${{ matrix.node }} and ${{ matrix.os }}${{ matrix.shard && format(' (shard {0}/3)', matrix.shard) || '' }} (push) Has been cancelled
CI / Build on Node ${{ matrix.node }} (push) Has been cancelled
CI / Style Check (push) Has been cancelled
CI / Generate Assets (push) Has been cancelled
CI / Check Python (3.14) (push) Has been cancelled
CI / Check Python (3.9) (push) Has been cancelled
CI / Build Docs (push) Has been cancelled
CI / Code Scan Action (push) Has been cancelled
CI / Site tests (push) Has been cancelled
CI / webui tests (push) Has been cancelled
CI / Run Integration Tests (push) Has been cancelled
CI / Run Smoke Tests (push) Has been cancelled
CI / Go Tests (push) Has been cancelled
CI / Share Test (push) Has been cancelled
CI / Redteam (Production API) (push) Has been cancelled
CI / Redteam (Staging API) (push) Has been cancelled
CI / GitHub Actions Lint (push) Has been cancelled
CI / Check Ruby (3.0) (push) Has been cancelled
release-please / release-please (push) Has been cancelled
release-please / build (push) Has been cancelled
release-please / publish-npm (push) Has been cancelled
release-please / publish-npm-backfill (push) Has been cancelled
release-please / docker (push) Has been cancelled
release-please / publish-code-scan-action (push) Has been cancelled
release-please / attest-code-scan-action (push) Has been cancelled
780 lines
27 KiB
TypeScript
780 lines
27 KiB
TypeScript
import path from 'path';
|
|
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { importModule } from '../../src/esm';
|
|
import logger from '../../src/logger';
|
|
import { FunctionCallbackHandler } from '../../src/providers/functionCallbackUtils';
|
|
|
|
import type { FunctionCallbackConfig } from '../../src/providers/functionCallbackTypes';
|
|
|
|
// Mock dependencies
|
|
vi.mock('../../src/cliState', () => ({ default: { basePath: '/test/basePath' } }));
|
|
vi.mock('../../src/esm', async (importOriginal) => {
|
|
return {
|
|
...(await importOriginal()),
|
|
importModule: vi.fn(),
|
|
};
|
|
});
|
|
vi.mock('../../src/logger', () => ({
|
|
default: { debug: vi.fn() },
|
|
}));
|
|
|
|
const mockImportModule = vi.mocked(importModule);
|
|
const mockLogger = vi.mocked(logger);
|
|
|
|
describe('FunctionCallbackHandler', () => {
|
|
let handler: FunctionCallbackHandler;
|
|
|
|
beforeEach(() => {
|
|
handler = new FunctionCallbackHandler();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('processCall', () => {
|
|
it('should return stringified call when no callback is available', async () => {
|
|
const call = { name: 'testFunction', arguments: '{"param": "value"}' };
|
|
const result = await handler.processCall(call, {});
|
|
|
|
expect(result).toEqual({
|
|
output: JSON.stringify(call),
|
|
isError: false,
|
|
});
|
|
});
|
|
|
|
it('should return stringified call when callbacks config is undefined', async () => {
|
|
const call = { name: 'testFunction', arguments: '{"param": "value"}' };
|
|
const result = await handler.processCall(call);
|
|
|
|
expect(result).toEqual({
|
|
output: JSON.stringify(call),
|
|
isError: false,
|
|
});
|
|
});
|
|
|
|
it('should return string call as-is when no callback available', async () => {
|
|
const call = 'some string call';
|
|
const result = await handler.processCall(call, {});
|
|
|
|
expect(result).toEqual({
|
|
output: call,
|
|
isError: false,
|
|
});
|
|
});
|
|
|
|
it('should execute function callback successfully', async () => {
|
|
const mockCallback = vi.fn().mockResolvedValue('callback result');
|
|
const callbacks: FunctionCallbackConfig = {
|
|
testFunction: mockCallback,
|
|
};
|
|
const call = { name: 'testFunction', arguments: '{"param": "value"}' };
|
|
|
|
const result = await handler.processCall(call, callbacks);
|
|
|
|
expect(result).toEqual({
|
|
output: 'callback result',
|
|
isError: false,
|
|
});
|
|
expect(mockCallback).toHaveBeenCalledWith('{"param": "value"}', undefined);
|
|
});
|
|
|
|
it('should pass context to callback', async () => {
|
|
const mockCallback = vi.fn().mockResolvedValue('callback result');
|
|
const callbacks: FunctionCallbackConfig = {
|
|
testFunction: mockCallback,
|
|
};
|
|
const call = { name: 'testFunction', arguments: '{"param": "value"}' };
|
|
const context = { threadId: 'thread-123', runId: 'run-456' };
|
|
|
|
const result = await handler.processCall(call, callbacks, context);
|
|
|
|
expect(result).toEqual({
|
|
output: 'callback result',
|
|
isError: false,
|
|
});
|
|
expect(mockCallback).toHaveBeenCalledWith('{"param": "value"}', context);
|
|
});
|
|
|
|
it('should handle tool call format', async () => {
|
|
const mockCallback = vi.fn().mockResolvedValue('tool result');
|
|
const callbacks: FunctionCallbackConfig = {
|
|
testTool: mockCallback,
|
|
};
|
|
const call = {
|
|
type: 'function',
|
|
function: {
|
|
name: 'testTool',
|
|
arguments: '{"toolParam": "toolValue"}',
|
|
},
|
|
};
|
|
|
|
const result = await handler.processCall(call, callbacks);
|
|
|
|
expect(result).toEqual({
|
|
output: 'tool result',
|
|
isError: false,
|
|
});
|
|
expect(mockCallback).toHaveBeenCalledWith('{"toolParam": "toolValue"}', undefined);
|
|
});
|
|
|
|
it('should stringify non-string callback results', async () => {
|
|
const mockCallback = vi.fn().mockResolvedValue({ result: 'object' });
|
|
const callbacks: FunctionCallbackConfig = {
|
|
testFunction: mockCallback,
|
|
};
|
|
const call = { name: 'testFunction', arguments: '{}' };
|
|
|
|
const result = await handler.processCall(call, callbacks);
|
|
|
|
expect(result).toEqual({
|
|
output: '{"result":"object"}',
|
|
isError: false,
|
|
});
|
|
});
|
|
|
|
it('should return original call on callback error', async () => {
|
|
const mockCallback = vi.fn().mockRejectedValue(new Error('Callback failed'));
|
|
const callbacks: FunctionCallbackConfig = {
|
|
testFunction: mockCallback,
|
|
};
|
|
const call = { name: 'testFunction', arguments: '{"param": "value"}' };
|
|
|
|
const result = await handler.processCall(call, callbacks);
|
|
|
|
expect(result).toEqual({
|
|
output: JSON.stringify(call),
|
|
isError: true,
|
|
});
|
|
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
'Function callback failed for testFunction: Error: Callback failed',
|
|
);
|
|
});
|
|
|
|
it('should handle calls with no function information', async () => {
|
|
const callbacks: FunctionCallbackConfig = {
|
|
testFunction: vi.fn() as any,
|
|
};
|
|
const call = { notAFunction: true };
|
|
|
|
const result = await handler.processCall(call, callbacks);
|
|
|
|
expect(result).toEqual({
|
|
output: JSON.stringify(call),
|
|
isError: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('processCalls', () => {
|
|
it('should return null/undefined calls as-is', async () => {
|
|
expect(await handler.processCalls(null)).toBeNull();
|
|
expect(await handler.processCalls(undefined)).toBeUndefined();
|
|
});
|
|
|
|
it('should process single call and return output directly', async () => {
|
|
const mockCallback = vi.fn().mockResolvedValue('single result');
|
|
const callbacks: FunctionCallbackConfig = {
|
|
testFunction: mockCallback,
|
|
};
|
|
const call = { name: 'testFunction', arguments: '{}' };
|
|
|
|
const result = await handler.processCalls(call, callbacks);
|
|
|
|
expect(result).toBe('single result');
|
|
});
|
|
|
|
it('should process array of calls and join string results', async () => {
|
|
const mockCallback1 = vi.fn().mockResolvedValue('result1');
|
|
const mockCallback2 = vi.fn().mockResolvedValue('result2');
|
|
const callbacks: FunctionCallbackConfig = {
|
|
func1: mockCallback1,
|
|
func2: mockCallback2,
|
|
};
|
|
const calls = [
|
|
{ name: 'func1', arguments: '{}' },
|
|
{ name: 'func2', arguments: '{}' },
|
|
];
|
|
|
|
const result = await handler.processCalls(calls, callbacks);
|
|
|
|
expect(result).toBe('result1\nresult2');
|
|
});
|
|
|
|
it('should return output array when results are not all strings', async () => {
|
|
const mockCallback1 = vi.fn().mockResolvedValue('string result');
|
|
const mockCallback2 = vi.fn().mockResolvedValue({ object: 'result' });
|
|
const callbacks: FunctionCallbackConfig = {
|
|
func1: mockCallback1,
|
|
func2: mockCallback2,
|
|
};
|
|
const calls = [
|
|
{ name: 'func1', arguments: '{}' },
|
|
{ name: 'func2', arguments: '{}' },
|
|
];
|
|
|
|
const result = await handler.processCalls(calls, callbacks);
|
|
|
|
// The second callback returns an object, which gets stringified, so both are strings
|
|
// and should be joined with newlines
|
|
expect(result).toBe('string result\n{"object":"result"}');
|
|
});
|
|
|
|
it('should return original calls when no callbacks succeed', async () => {
|
|
const callbacks: FunctionCallbackConfig = {};
|
|
const calls = [
|
|
{ name: 'unknownFunc1', arguments: '{}' },
|
|
{ name: 'unknownFunc2', arguments: '{}' },
|
|
];
|
|
|
|
const result = await handler.processCalls(calls, callbacks);
|
|
|
|
expect(result).toEqual(calls);
|
|
});
|
|
|
|
it('should return stringified single call when no callback succeeds', async () => {
|
|
const callbacks: FunctionCallbackConfig = {};
|
|
const call = { name: 'unknownFunc', arguments: '{}' };
|
|
|
|
const result = await handler.processCalls(call, callbacks);
|
|
|
|
expect(result).toBe(JSON.stringify(call));
|
|
});
|
|
});
|
|
|
|
describe('executeCallback', () => {
|
|
it('should cache and reuse loaded callbacks', async () => {
|
|
const mockCallback = vi.fn().mockResolvedValue('cached result');
|
|
const callbacks: FunctionCallbackConfig = {
|
|
testFunction: mockCallback,
|
|
};
|
|
|
|
// Call multiple times
|
|
await handler.processCall({ name: 'testFunction', arguments: '{}' }, callbacks);
|
|
await handler.processCall({ name: 'testFunction', arguments: '{}' }, callbacks);
|
|
|
|
expect(mockCallback).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should load external file-based callbacks', async () => {
|
|
const mockExternalFunction = vi.fn().mockResolvedValue('external result');
|
|
mockImportModule.mockResolvedValue({ default: mockExternalFunction });
|
|
|
|
const callbacks: FunctionCallbackConfig = {
|
|
testFunction: 'file://path/to/function.js',
|
|
};
|
|
const call = { name: 'testFunction', arguments: '{"param": "value"}' };
|
|
|
|
const result = await handler.processCall(call, callbacks);
|
|
|
|
expect(result).toEqual({
|
|
output: 'external result',
|
|
isError: false,
|
|
});
|
|
expect(mockImportModule).toHaveBeenCalledWith(
|
|
path.resolve('/test/basePath', 'path/to/function.js'),
|
|
);
|
|
expect(mockExternalFunction).toHaveBeenCalledWith('{"param": "value"}', undefined);
|
|
});
|
|
|
|
it('should load specific function from external file', async () => {
|
|
const mockSpecificFunction = vi.fn().mockResolvedValue('specific result');
|
|
const mockModule = {
|
|
default: vi.fn(),
|
|
specificFunction: mockSpecificFunction,
|
|
};
|
|
mockImportModule.mockResolvedValue(mockModule);
|
|
|
|
const callbacks: FunctionCallbackConfig = {
|
|
testFunction: 'file://path/to/functions.js:specificFunction',
|
|
};
|
|
const call = { name: 'testFunction', arguments: '{}' };
|
|
|
|
const result = await handler.processCall(call, callbacks);
|
|
|
|
expect(result).toEqual({
|
|
output: 'specific result',
|
|
isError: false,
|
|
});
|
|
expect(mockSpecificFunction).toHaveBeenCalledWith('{}', undefined);
|
|
});
|
|
|
|
it('should load specific function from Windows-style external file paths', async () => {
|
|
const mockSpecificFunction = vi.fn().mockResolvedValue('windows result');
|
|
const mockModule = {
|
|
default: vi.fn(),
|
|
windowsHandler: mockSpecificFunction,
|
|
};
|
|
mockImportModule.mockResolvedValue(mockModule);
|
|
|
|
const callbacks: FunctionCallbackConfig = {
|
|
testFunction: 'file://C:/path/to/functions.js:windowsHandler',
|
|
};
|
|
const call = { name: 'testFunction', arguments: '{}' };
|
|
|
|
const result = await handler.processCall(call, callbacks);
|
|
|
|
expect(result).toEqual({
|
|
output: 'windows result',
|
|
isError: false,
|
|
});
|
|
expect(mockImportModule).toHaveBeenCalledWith(
|
|
path.resolve('/test/basePath', 'C:/path/to/functions.js'),
|
|
);
|
|
expect(mockSpecificFunction).toHaveBeenCalledWith('{}', undefined);
|
|
});
|
|
|
|
it('should load default function from standard Windows file URLs on Windows', async () => {
|
|
const mockDefaultFunction = vi.fn().mockResolvedValue('windows default result');
|
|
mockImportModule.mockResolvedValue({ default: mockDefaultFunction });
|
|
const originalPlatform = process.platform;
|
|
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
|
|
|
try {
|
|
const callbacks: FunctionCallbackConfig = {
|
|
testFunction: 'file:///C:/path/to/functions.js',
|
|
};
|
|
const result = await handler.processCall(
|
|
{ name: 'testFunction', arguments: '{}' },
|
|
callbacks,
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
output: 'windows default result',
|
|
isError: false,
|
|
});
|
|
expect(mockImportModule).toHaveBeenCalledWith(
|
|
path.resolve('/test/basePath', 'C:/path/to/functions.js'),
|
|
);
|
|
} finally {
|
|
Object.defineProperty(process, 'platform', {
|
|
value: originalPlatform,
|
|
configurable: true,
|
|
});
|
|
}
|
|
});
|
|
|
|
it('should preserve colons in default-export external file paths', async () => {
|
|
const mockDefaultFunction = vi.fn().mockResolvedValue('colon path result');
|
|
mockImportModule.mockResolvedValue({ default: mockDefaultFunction });
|
|
|
|
const callbacks: FunctionCallbackConfig = {
|
|
testFunction: 'file://path/to/functions:default.js',
|
|
};
|
|
const result = await handler.processCall(
|
|
{ name: 'testFunction', arguments: '{}' },
|
|
callbacks,
|
|
);
|
|
|
|
expect(result).toEqual({
|
|
output: 'colon path result',
|
|
isError: false,
|
|
});
|
|
expect(mockImportModule).toHaveBeenCalledWith(
|
|
path.resolve('/test/basePath', 'path/to/functions:default.js'),
|
|
);
|
|
});
|
|
|
|
it('should handle inline function strings', async () => {
|
|
const callbacks: FunctionCallbackConfig = {
|
|
testFunction: '() => "inline result"',
|
|
};
|
|
const call = { name: 'testFunction', arguments: '{}' };
|
|
|
|
const result = await handler.processCall(call, callbacks);
|
|
|
|
expect(result).toEqual({
|
|
output: 'inline result',
|
|
isError: false,
|
|
});
|
|
});
|
|
|
|
it('should throw error for invalid callback configuration', async () => {
|
|
const callbacks: FunctionCallbackConfig = {
|
|
testFunction: 123 as any, // Invalid callback type
|
|
};
|
|
const call = { name: 'testFunction', arguments: '{}' };
|
|
|
|
const result = await handler.processCall(call, callbacks);
|
|
|
|
expect(result.isError).toBe(true);
|
|
expect(result.output).toBe(JSON.stringify(call));
|
|
});
|
|
|
|
it('should handle errors when loading external functions', async () => {
|
|
mockImportModule.mockRejectedValue(new Error('Module not found'));
|
|
|
|
const callbacks: FunctionCallbackConfig = {
|
|
testFunction: 'file://nonexistent/function.js',
|
|
};
|
|
const call = { name: 'testFunction', arguments: '{}' };
|
|
|
|
const result = await handler.processCall(call, callbacks);
|
|
|
|
expect(result.isError).toBe(true);
|
|
expect(result.output).toBe(JSON.stringify(call));
|
|
expect(mockLogger.debug).toHaveBeenCalledWith(
|
|
expect.stringContaining('Function callback failed for testFunction'),
|
|
);
|
|
});
|
|
|
|
it('should handle non-function exports from external files', async () => {
|
|
mockImportModule.mockResolvedValue({ default: 'not a function' });
|
|
|
|
const callbacks: FunctionCallbackConfig = {
|
|
testFunction: 'file://path/to/invalid.js',
|
|
};
|
|
const call = { name: 'testFunction', arguments: '{}' };
|
|
|
|
const result = await handler.processCall(call, callbacks);
|
|
|
|
expect(result.isError).toBe(true);
|
|
expect(result.output).toBe(JSON.stringify(call));
|
|
});
|
|
});
|
|
|
|
describe('clearCache', () => {
|
|
it('should clear cached callbacks', async () => {
|
|
const mockCallback = vi.fn().mockResolvedValue('result');
|
|
const callbacks: FunctionCallbackConfig = {
|
|
testFunction: mockCallback,
|
|
};
|
|
|
|
// Load callback into cache
|
|
await handler.processCall({ name: 'testFunction', arguments: '{}' }, callbacks);
|
|
|
|
// Clear cache
|
|
handler.clearCache();
|
|
|
|
// Mock a different implementation
|
|
const newMockCallback = vi.fn().mockResolvedValue('new result');
|
|
const newCallbacks: FunctionCallbackConfig = {
|
|
testFunction: newMockCallback,
|
|
};
|
|
|
|
// This should use the new callback, not the cached one
|
|
const result = await handler.processCall(
|
|
{ name: 'testFunction', arguments: '{}' },
|
|
newCallbacks,
|
|
);
|
|
|
|
expect(result.output).toBe('new result');
|
|
expect(newMockCallback).toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('extractFunctionInfo', () => {
|
|
it('should extract info from direct function call format', async () => {
|
|
const call = { name: 'testFunc', arguments: '{"key": "value"}' };
|
|
const result = await handler.processCall(call, {});
|
|
|
|
// Should return stringified since no callback available
|
|
expect(result.output).toBe(JSON.stringify(call));
|
|
});
|
|
|
|
it('should extract info from tool call format', async () => {
|
|
const call = {
|
|
type: 'function',
|
|
function: {
|
|
name: 'testTool',
|
|
arguments: '{"key": "value"}',
|
|
},
|
|
};
|
|
const result = await handler.processCall(call, {});
|
|
|
|
// Should return stringified since no callback available
|
|
expect(result.output).toBe(JSON.stringify(call));
|
|
});
|
|
|
|
it('should return null for invalid call formats', async () => {
|
|
const invalidCalls = [null, undefined, 'string', 123, { invalidFormat: true }];
|
|
|
|
for (const call of invalidCalls) {
|
|
const result = await handler.processCall(call, {});
|
|
if (typeof call === 'string') {
|
|
expect(result.output).toBe(call);
|
|
} else {
|
|
expect(result.output).toBe(JSON.stringify(call));
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('MCP Integration', () => {
|
|
let mockMCPClient: {
|
|
getAllTools: ReturnType<typeof vi.fn>;
|
|
callTool: ReturnType<typeof vi.fn>;
|
|
};
|
|
|
|
beforeEach(() => {
|
|
mockMCPClient = {
|
|
getAllTools: vi.fn(),
|
|
callTool: vi.fn(),
|
|
};
|
|
handler = new FunctionCallbackHandler(mockMCPClient as any);
|
|
});
|
|
|
|
it('should execute MCP tool when tool name matches available MCP tools', async () => {
|
|
mockMCPClient.getAllTools.mockReturnValue([
|
|
{ name: 'list_resources', description: 'List available resources' },
|
|
{ name: 'get_resource', description: 'Get specific resource' },
|
|
]);
|
|
mockMCPClient.callTool.mockResolvedValue({
|
|
content: 'Resource list: [file1.txt, file2.txt]',
|
|
});
|
|
|
|
const call = { name: 'list_resources', arguments: '{}' };
|
|
const result = await handler.processCall(call, {});
|
|
|
|
expect(mockMCPClient.callTool).toHaveBeenCalledWith('list_resources', {});
|
|
expect(result).toEqual({
|
|
output: 'MCP Tool Result (list_resources): Resource list: [file1.txt, file2.txt]',
|
|
isError: false,
|
|
});
|
|
});
|
|
|
|
it('should handle MCP tool errors gracefully', async () => {
|
|
mockMCPClient.getAllTools.mockReturnValue([
|
|
{ name: 'failing_tool', description: 'A tool that fails' },
|
|
]);
|
|
mockMCPClient.callTool.mockResolvedValue({
|
|
content: 'Tool execution failed: Invalid arguments',
|
|
isError: true,
|
|
});
|
|
|
|
const call = { name: 'failing_tool', arguments: '{"invalid": true}' };
|
|
const result = await handler.processCall(call, {});
|
|
|
|
expect(result).toEqual({
|
|
output: 'MCP Tool Error (failing_tool): Tool execution failed: Invalid arguments',
|
|
isError: true,
|
|
});
|
|
});
|
|
|
|
it('should surface MCP results that resolve with an error field', async () => {
|
|
mockMCPClient.getAllTools.mockReturnValue([
|
|
{ name: 'failing_tool', description: 'A tool that fails' },
|
|
]);
|
|
mockMCPClient.callTool.mockResolvedValue({
|
|
content: '',
|
|
error: 'Connection refused',
|
|
});
|
|
|
|
const call = { name: 'failing_tool', arguments: '{}' };
|
|
const result = await handler.processCall(call, {});
|
|
|
|
expect(result).toEqual({
|
|
output: 'MCP Tool Error (failing_tool): Connection refused',
|
|
isError: true,
|
|
});
|
|
});
|
|
|
|
it('should fall back to a generic message when an error result has no content', async () => {
|
|
mockMCPClient.getAllTools.mockReturnValue([
|
|
{ name: 'failing_tool', description: 'A tool that fails' },
|
|
]);
|
|
mockMCPClient.callTool.mockResolvedValue({
|
|
content: '',
|
|
isError: true,
|
|
});
|
|
|
|
const call = { name: 'failing_tool', arguments: '{}' };
|
|
const result = await handler.processCall(call, {});
|
|
|
|
expect(result).toEqual({
|
|
output: 'MCP Tool Error (failing_tool): Tool returned an error result',
|
|
isError: true,
|
|
});
|
|
});
|
|
|
|
it('should handle MCP client exceptions', async () => {
|
|
mockMCPClient.getAllTools.mockReturnValue([
|
|
{ name: 'error_tool', description: 'A tool that throws' },
|
|
]);
|
|
mockMCPClient.callTool.mockRejectedValue(new Error('Connection lost'));
|
|
|
|
const call = { name: 'error_tool', arguments: '{}' };
|
|
const result = await handler.processCall(call, {});
|
|
|
|
expect(result).toEqual({
|
|
output: 'MCP Tool Error (error_tool): Connection lost',
|
|
isError: true,
|
|
});
|
|
});
|
|
|
|
it('should handle invalid JSON arguments in MCP tools', async () => {
|
|
mockMCPClient.getAllTools.mockReturnValue([
|
|
{ name: 'json_tool', description: 'A tool requiring JSON args' },
|
|
]);
|
|
|
|
const call = { name: 'json_tool', arguments: 'invalid-json' };
|
|
const result = await handler.processCall(call, {});
|
|
|
|
expect(result.isError).toBe(true);
|
|
expect(result.output).toContain('MCP Tool Error (json_tool)');
|
|
});
|
|
|
|
it('should fall back to function callbacks when tool is not an MCP tool', async () => {
|
|
mockMCPClient.getAllTools.mockReturnValue([{ name: 'mcp_tool', description: 'An MCP tool' }]);
|
|
|
|
const callbacks: FunctionCallbackConfig = {
|
|
regular_function: async (_args: string) => 'callback result',
|
|
};
|
|
const call = { name: 'regular_function', arguments: '{}' };
|
|
const result = await handler.processCall(call, callbacks);
|
|
|
|
expect(mockMCPClient.callTool).not.toHaveBeenCalled();
|
|
expect(result).toEqual({
|
|
output: 'callback result',
|
|
isError: false,
|
|
});
|
|
});
|
|
|
|
it('should prioritize MCP tools over function callbacks when both exist', async () => {
|
|
mockMCPClient.getAllTools.mockReturnValue([
|
|
{ name: 'shared_name', description: 'MCP tool with same name as callback' },
|
|
]);
|
|
mockMCPClient.callTool.mockResolvedValue({
|
|
content: 'MCP tool result',
|
|
});
|
|
|
|
const callbacks: FunctionCallbackConfig = {
|
|
shared_name: async (_args: string) => 'callback result',
|
|
};
|
|
const call = { name: 'shared_name', arguments: '{}' };
|
|
const result = await handler.processCall(call, callbacks);
|
|
|
|
expect(mockMCPClient.callTool).toHaveBeenCalledWith('shared_name', {});
|
|
expect(result).toEqual({
|
|
output: 'MCP Tool Result (shared_name): MCP tool result',
|
|
isError: false,
|
|
});
|
|
});
|
|
|
|
it('should work without MCP client (backwards compatibility)', async () => {
|
|
const handlerWithoutMCP = new FunctionCallbackHandler();
|
|
const callbacks: FunctionCallbackConfig = {
|
|
test_function: async (_args: string) => 'no MCP result',
|
|
};
|
|
const call = { name: 'test_function', arguments: '{}' };
|
|
const result = await handlerWithoutMCP.processCall(call, callbacks);
|
|
|
|
expect(result).toEqual({
|
|
output: 'no MCP result',
|
|
isError: false,
|
|
});
|
|
});
|
|
|
|
it('should handle empty arguments string for MCP tools', async () => {
|
|
mockMCPClient.getAllTools.mockReturnValue([
|
|
{ name: 'no_args_tool', description: 'Tool with no arguments' },
|
|
]);
|
|
mockMCPClient.callTool.mockResolvedValue({
|
|
content: 'success with no args',
|
|
});
|
|
|
|
const call = { name: 'no_args_tool', arguments: '' };
|
|
const result = await handler.processCall(call, {});
|
|
|
|
expect(mockMCPClient.callTool).toHaveBeenCalledWith('no_args_tool', {});
|
|
expect(result).toEqual({
|
|
output: 'MCP Tool Result (no_args_tool): success with no args',
|
|
isError: false,
|
|
});
|
|
});
|
|
|
|
it('should handle missing arguments property for MCP tools', async () => {
|
|
mockMCPClient.getAllTools.mockReturnValue([
|
|
{ name: 'missing_args_tool', description: 'Tool with missing args property' },
|
|
]);
|
|
mockMCPClient.callTool.mockResolvedValue({
|
|
content: 'success with missing args',
|
|
});
|
|
|
|
const call = { name: 'missing_args_tool' }; // No arguments property
|
|
const result = await handler.processCall(call, {});
|
|
|
|
expect(mockMCPClient.callTool).toHaveBeenCalledWith('missing_args_tool', {});
|
|
expect(result).toEqual({
|
|
output: 'MCP Tool Result (missing_args_tool): success with missing args',
|
|
isError: false,
|
|
});
|
|
});
|
|
|
|
it('should format non-string MCP content without [object Object]', async () => {
|
|
mockMCPClient.getAllTools.mockReturnValue([
|
|
{ name: 'rich_tool', description: 'Tool with rich content' },
|
|
]);
|
|
mockMCPClient.callTool.mockResolvedValue({
|
|
content: [
|
|
{ type: 'text', text: 'Part A' },
|
|
{ type: 'json', json: { foo: 'bar' } },
|
|
{ type: 'data', data: { key: 'value' } },
|
|
{ type: 'unknown', other: 'stuff' },
|
|
'plain string part',
|
|
null,
|
|
undefined,
|
|
],
|
|
});
|
|
|
|
const call = { name: 'rich_tool', arguments: '{}' };
|
|
const result = await handler.processCall(call, {});
|
|
|
|
expect(result.isError).toBe(false);
|
|
expect(result.output).toContain('Part A');
|
|
expect(result.output).toContain('"foo":"bar"');
|
|
expect(result.output).toContain('"key":"value"');
|
|
expect(result.output).toContain('plain string part');
|
|
expect(result.output).not.toContain('[object Object]');
|
|
});
|
|
|
|
it('should handle object MCP content without [object Object]', async () => {
|
|
mockMCPClient.getAllTools.mockReturnValue([
|
|
{ name: 'object_tool', description: 'Tool returning object' },
|
|
]);
|
|
mockMCPClient.callTool.mockResolvedValue({
|
|
content: { type: 'response', data: { result: 'success', count: 42 } },
|
|
});
|
|
|
|
const call = { name: 'object_tool', arguments: '{}' };
|
|
const result = await handler.processCall(call, {});
|
|
|
|
expect(result.isError).toBe(false);
|
|
expect(result.output).toContain('"result":"success"');
|
|
expect(result.output).toContain('"count":42');
|
|
expect(result.output).not.toContain('[object Object]');
|
|
});
|
|
|
|
it('should handle null/undefined MCP content gracefully', async () => {
|
|
mockMCPClient.getAllTools.mockReturnValue([
|
|
{ name: 'empty_tool', description: 'Tool with empty content' },
|
|
]);
|
|
mockMCPClient.callTool.mockResolvedValue({
|
|
content: null,
|
|
});
|
|
|
|
const call = { name: 'empty_tool', arguments: '{}' };
|
|
const result = await handler.processCall(call, {});
|
|
|
|
expect(result.isError).toBe(false);
|
|
expect(result.output).toBe('MCP Tool Result (empty_tool): ');
|
|
});
|
|
|
|
it('should handle non-object arguments passed directly', async () => {
|
|
mockMCPClient.getAllTools.mockReturnValue([
|
|
{ name: 'direct_args_tool', description: 'Tool with direct args' },
|
|
]);
|
|
mockMCPClient.callTool.mockResolvedValue({
|
|
content: 'success with direct args',
|
|
});
|
|
|
|
const call = { name: 'direct_args_tool', arguments: { param: 'value' } }; // Direct object, not string
|
|
const result = await handler.processCall(call, {});
|
|
|
|
expect(mockMCPClient.callTool).toHaveBeenCalledWith('direct_args_tool', { param: 'value' });
|
|
expect(result).toEqual({
|
|
output: 'MCP Tool Result (direct_args_tool): success with direct args',
|
|
isError: false,
|
|
});
|
|
});
|
|
});
|
|
});
|