|
|
import { describe, it, expect, beforeEach } from "bun:test"; |
|
|
import { ToolService } from "../src/tool-service"; |
|
|
import type { ToolDefinition, ToolCall } from "../src/types"; |
|
|
|
|
|
describe("ToolService", () => { |
|
|
let toolService: ToolService; |
|
|
|
|
|
beforeEach(() => { |
|
|
toolService = new ToolService(); |
|
|
}); |
|
|
|
|
|
describe("generateToolSystemPrompt", () => { |
|
|
it("should generate a basic system prompt with tools", () => { |
|
|
const tools: ToolDefinition[] = [ |
|
|
{ |
|
|
type: "function", |
|
|
function: { |
|
|
name: "get_weather", |
|
|
description: "Get current weather for a location", |
|
|
parameters: { |
|
|
type: "object", |
|
|
properties: { |
|
|
location: { |
|
|
type: "string", |
|
|
description: "The city and state, e.g. San Francisco, CA", |
|
|
}, |
|
|
}, |
|
|
required: ["location"], |
|
|
}, |
|
|
}, |
|
|
}, |
|
|
]; |
|
|
|
|
|
const prompt = toolService.generateToolSystemPrompt(tools); |
|
|
|
|
|
expect(prompt).toContain("get_weather"); |
|
|
expect(prompt).toContain("Get current weather for a location"); |
|
|
expect(prompt).toContain("tool_calls"); |
|
|
expect(prompt).toContain("location (string, required)"); |
|
|
}); |
|
|
|
|
|
it("should handle tool_choice 'required'", () => { |
|
|
const tools: ToolDefinition[] = [ |
|
|
{ |
|
|
type: "function", |
|
|
function: { |
|
|
name: "calculate", |
|
|
description: "Perform calculations", |
|
|
}, |
|
|
}, |
|
|
]; |
|
|
|
|
|
const prompt = toolService.generateToolSystemPrompt(tools, "required"); |
|
|
expect(prompt).toContain("You MUST call at least one function"); |
|
|
}); |
|
|
|
|
|
it("should handle tool_choice 'none'", () => { |
|
|
const tools: ToolDefinition[] = [ |
|
|
{ |
|
|
type: "function", |
|
|
function: { |
|
|
name: "calculate", |
|
|
description: "Perform calculations", |
|
|
}, |
|
|
}, |
|
|
]; |
|
|
|
|
|
const prompt = toolService.generateToolSystemPrompt(tools, "none"); |
|
|
expect(prompt).toContain("Do NOT call any functions"); |
|
|
}); |
|
|
|
|
|
it("should handle specific function tool_choice", () => { |
|
|
const tools: ToolDefinition[] = [ |
|
|
{ |
|
|
type: "function", |
|
|
function: { |
|
|
name: "get_weather", |
|
|
description: "Get weather", |
|
|
}, |
|
|
}, |
|
|
]; |
|
|
|
|
|
const prompt = toolService.generateToolSystemPrompt(tools, { |
|
|
type: "function", |
|
|
function: { name: "get_weather" }, |
|
|
}); |
|
|
expect(prompt).toContain('You MUST call the function "get_weather"'); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe("detectFunctionCalls", () => { |
|
|
it("should detect valid JSON function calls", () => { |
|
|
const response = JSON.stringify({ |
|
|
tool_calls: [ |
|
|
{ |
|
|
id: "call_1", |
|
|
type: "function", |
|
|
function: { |
|
|
name: "get_weather", |
|
|
arguments: '{"location": "New York"}', |
|
|
}, |
|
|
}, |
|
|
], |
|
|
}); |
|
|
|
|
|
expect(toolService.detectFunctionCalls(response)).toBe(true); |
|
|
}); |
|
|
|
|
|
it("should detect partial function call patterns", () => { |
|
|
const response = 'Here is the result: "tool_calls": [{"id": "call_1"}]'; |
|
|
expect(toolService.detectFunctionCalls(response)).toBe(true); |
|
|
}); |
|
|
|
|
|
it("should return false for regular text", () => { |
|
|
const response = |
|
|
"This is just a regular response without any function calls."; |
|
|
expect(toolService.detectFunctionCalls(response)).toBe(false); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe("extractFunctionCalls", () => { |
|
|
it("should extract function calls from valid JSON", () => { |
|
|
const response = JSON.stringify({ |
|
|
tool_calls: [ |
|
|
{ |
|
|
id: "call_1", |
|
|
type: "function", |
|
|
function: { |
|
|
name: "get_weather", |
|
|
arguments: '{"location": "New York"}', |
|
|
}, |
|
|
}, |
|
|
], |
|
|
}); |
|
|
|
|
|
const calls = toolService.extractFunctionCalls(response); |
|
|
expect(calls).toHaveLength(1); |
|
|
expect(calls[0].function.name).toBe("get_weather"); |
|
|
expect(calls[0].function.arguments).toBe('{"location": "New York"}'); |
|
|
}); |
|
|
|
|
|
it("should handle missing IDs by generating them", () => { |
|
|
const response = JSON.stringify({ |
|
|
tool_calls: [ |
|
|
{ |
|
|
type: "function", |
|
|
function: { |
|
|
name: "calculate", |
|
|
arguments: '{"expression": "2+2"}', |
|
|
}, |
|
|
}, |
|
|
], |
|
|
}); |
|
|
|
|
|
const calls = toolService.extractFunctionCalls(response); |
|
|
expect(calls).toHaveLength(1); |
|
|
expect(calls[0].id).toMatch(/^call_\d+_0$/); |
|
|
}); |
|
|
|
|
|
it("should return empty array for invalid input", () => { |
|
|
const response = "No function calls here"; |
|
|
const calls = toolService.extractFunctionCalls(response); |
|
|
expect(calls).toHaveLength(0); |
|
|
}); |
|
|
|
|
|
it("should handle object arguments by stringifying them", () => { |
|
|
const response = JSON.stringify({ |
|
|
tool_calls: [ |
|
|
{ |
|
|
id: "call_1", |
|
|
type: "function", |
|
|
function: { |
|
|
name: "test", |
|
|
arguments: { key: "value" }, |
|
|
}, |
|
|
}, |
|
|
], |
|
|
}); |
|
|
|
|
|
const calls = toolService.extractFunctionCalls(response); |
|
|
expect(calls[0].function.arguments).toBe('{"key":"value"}'); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe("executeFunctionCall", () => { |
|
|
it("should execute a valid function call", async () => { |
|
|
const mockFunction = (args: any) => `Hello ${args.name}!`; |
|
|
const availableFunctions = { greet: mockFunction }; |
|
|
|
|
|
const toolCall: ToolCall = { |
|
|
id: "call_1", |
|
|
type: "function", |
|
|
function: { |
|
|
name: "greet", |
|
|
arguments: '{"name": "World"}', |
|
|
}, |
|
|
}; |
|
|
|
|
|
const result = await toolService.executeFunctionCall( |
|
|
toolCall, |
|
|
availableFunctions |
|
|
); |
|
|
expect(result).toBe("Hello World!"); |
|
|
}); |
|
|
|
|
|
it("should handle function not found", async () => { |
|
|
const toolCall: ToolCall = { |
|
|
id: "call_1", |
|
|
type: "function", |
|
|
function: { |
|
|
name: "nonexistent", |
|
|
arguments: "{}", |
|
|
}, |
|
|
}; |
|
|
|
|
|
const result = await toolService.executeFunctionCall(toolCall, {}); |
|
|
const parsed = JSON.parse(result); |
|
|
expect(parsed.error).toContain("Function 'nonexistent' not found"); |
|
|
}); |
|
|
|
|
|
it("should handle invalid JSON arguments", async () => { |
|
|
const mockFunction = () => "test"; |
|
|
const availableFunctions = { test: mockFunction }; |
|
|
|
|
|
const toolCall: ToolCall = { |
|
|
id: "call_1", |
|
|
type: "function", |
|
|
function: { |
|
|
name: "test", |
|
|
arguments: "invalid json", |
|
|
}, |
|
|
}; |
|
|
|
|
|
const result = await toolService.executeFunctionCall( |
|
|
toolCall, |
|
|
availableFunctions |
|
|
); |
|
|
const parsed = JSON.parse(result); |
|
|
expect(parsed.error).toContain("Error executing function"); |
|
|
}); |
|
|
|
|
|
it("should handle function execution errors", async () => { |
|
|
const errorFunction = () => { |
|
|
throw new Error("Function failed"); |
|
|
}; |
|
|
const availableFunctions = { error_func: errorFunction }; |
|
|
|
|
|
const toolCall: ToolCall = { |
|
|
id: "call_1", |
|
|
type: "function", |
|
|
function: { |
|
|
name: "error_func", |
|
|
arguments: "{}", |
|
|
}, |
|
|
}; |
|
|
|
|
|
const result = await toolService.executeFunctionCall( |
|
|
toolCall, |
|
|
availableFunctions |
|
|
); |
|
|
const parsed = JSON.parse(result); |
|
|
expect(parsed.error).toContain("Function failed"); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe("createToolResultMessage", () => { |
|
|
it("should create a proper tool result message", () => { |
|
|
const message = toolService.createToolResultMessage( |
|
|
"call_1", |
|
|
"Result content" |
|
|
); |
|
|
|
|
|
expect(message.role).toBe("tool"); |
|
|
expect(message.content).toBe("Result content"); |
|
|
expect(message.tool_call_id).toBe("call_1"); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe("validateTools", () => { |
|
|
it("should validate correct tool definitions", () => { |
|
|
const tools: ToolDefinition[] = [ |
|
|
{ |
|
|
type: "function", |
|
|
function: { |
|
|
name: "test_function", |
|
|
description: "A test function", |
|
|
parameters: { |
|
|
type: "object", |
|
|
properties: { |
|
|
param1: { type: "string" }, |
|
|
}, |
|
|
required: ["param1"], |
|
|
}, |
|
|
}, |
|
|
}, |
|
|
]; |
|
|
|
|
|
const result = toolService.validateTools(tools); |
|
|
expect(result.valid).toBe(true); |
|
|
expect(result.errors).toHaveLength(0); |
|
|
}); |
|
|
|
|
|
it("should reject non-array tools", () => { |
|
|
const result = toolService.validateTools("not an array" as any); |
|
|
expect(result.valid).toBe(false); |
|
|
expect(result.errors).toContain("Tools must be an array"); |
|
|
}); |
|
|
|
|
|
it("should reject tools without function type", () => { |
|
|
const tools = [ |
|
|
{ |
|
|
type: "invalid", |
|
|
function: { name: "test" }, |
|
|
}, |
|
|
] as any; |
|
|
|
|
|
const result = toolService.validateTools(tools); |
|
|
expect(result.valid).toBe(false); |
|
|
expect(result.errors[0]).toContain('type must be "function"'); |
|
|
}); |
|
|
|
|
|
it("should reject tools without function definition", () => { |
|
|
const tools = [ |
|
|
{ |
|
|
type: "function", |
|
|
}, |
|
|
] as any; |
|
|
|
|
|
const result = toolService.validateTools(tools); |
|
|
expect(result.valid).toBe(false); |
|
|
expect(result.errors[0]).toContain("function definition is required"); |
|
|
}); |
|
|
|
|
|
it("should reject tools without function name", () => { |
|
|
const tools = [ |
|
|
{ |
|
|
type: "function", |
|
|
function: {}, |
|
|
}, |
|
|
] as any; |
|
|
|
|
|
const result = toolService.validateTools(tools); |
|
|
expect(result.valid).toBe(false); |
|
|
expect(result.errors[0]).toContain("function name is required"); |
|
|
}); |
|
|
|
|
|
it("should reject tools with invalid parameters type", () => { |
|
|
const tools = [ |
|
|
{ |
|
|
type: "function", |
|
|
function: { |
|
|
name: "test", |
|
|
parameters: { |
|
|
type: "array", |
|
|
}, |
|
|
}, |
|
|
}, |
|
|
] as any; |
|
|
|
|
|
const result = toolService.validateTools(tools); |
|
|
expect(result.valid).toBe(false); |
|
|
expect(result.errors[0]).toContain('parameters type must be "object"'); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe("shouldUseFunctionCalling", () => { |
|
|
it("should return true when tools are provided", () => { |
|
|
const tools: ToolDefinition[] = [ |
|
|
{ |
|
|
type: "function", |
|
|
function: { name: "test" }, |
|
|
}, |
|
|
]; |
|
|
|
|
|
expect(toolService.shouldUseFunctionCalling(tools)).toBe(true); |
|
|
}); |
|
|
|
|
|
it("should return false when no tools provided", () => { |
|
|
expect(toolService.shouldUseFunctionCalling()).toBe(false); |
|
|
expect(toolService.shouldUseFunctionCalling([])).toBe(false); |
|
|
}); |
|
|
|
|
|
it("should return false when tool_choice is 'none'", () => { |
|
|
const tools: ToolDefinition[] = [ |
|
|
{ |
|
|
type: "function", |
|
|
function: { name: "test" }, |
|
|
}, |
|
|
]; |
|
|
|
|
|
expect(toolService.shouldUseFunctionCalling(tools, "none")).toBe(false); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe("generateToolCallId", () => { |
|
|
it("should generate unique IDs", () => { |
|
|
const id1 = toolService.generateToolCallId(); |
|
|
const id2 = toolService.generateToolCallId(); |
|
|
|
|
|
expect(id1).toMatch(/^call_\d+_[a-z0-9]+$/); |
|
|
expect(id2).toMatch(/^call_\d+_[a-z0-9]+$/); |
|
|
expect(id1).not.toBe(id2); |
|
|
}); |
|
|
}); |
|
|
|
|
|
describe("Edge Cases and Robustness", () => { |
|
|
it("should handle empty tool calls array", () => { |
|
|
const response = JSON.stringify({ tool_calls: [] }); |
|
|
expect(toolService.detectFunctionCalls(response)).toBe(false); |
|
|
expect(toolService.extractFunctionCalls(response)).toHaveLength(0); |
|
|
}); |
|
|
|
|
|
it("should handle malformed JSON with partial tool_calls", () => { |
|
|
const response = |
|
|
'{"tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "test"'; |
|
|
expect(toolService.detectFunctionCalls(response)).toBe(true); |
|
|
const calls = toolService.extractFunctionCalls(response); |
|
|
expect(calls).toHaveLength(0); |
|
|
}); |
|
|
|
|
|
it("should handle multiple function calls in one response", () => { |
|
|
const response = JSON.stringify({ |
|
|
tool_calls: [ |
|
|
{ |
|
|
id: "call_1", |
|
|
type: "function", |
|
|
function: { name: "func1", arguments: '{"arg1": "value1"}' }, |
|
|
}, |
|
|
{ |
|
|
id: "call_2", |
|
|
type: "function", |
|
|
function: { name: "func2", arguments: '{"arg2": "value2"}' }, |
|
|
}, |
|
|
], |
|
|
}); |
|
|
|
|
|
const calls = toolService.extractFunctionCalls(response); |
|
|
expect(calls).toHaveLength(2); |
|
|
expect(calls[0].function.name).toBe("func1"); |
|
|
expect(calls[1].function.name).toBe("func2"); |
|
|
}); |
|
|
|
|
|
it("should handle async function execution", async () => { |
|
|
const asyncFunction = async (args: any) => { |
|
|
await new Promise((resolve) => setTimeout(resolve, 10)); |
|
|
return `Async result: ${args.input}`; |
|
|
}; |
|
|
const availableFunctions = { async_test: asyncFunction }; |
|
|
|
|
|
const toolCall: ToolCall = { |
|
|
id: "call_1", |
|
|
type: "function", |
|
|
function: { |
|
|
name: "async_test", |
|
|
arguments: '{"input": "test"}', |
|
|
}, |
|
|
}; |
|
|
|
|
|
const result = await toolService.executeFunctionCall( |
|
|
toolCall, |
|
|
availableFunctions |
|
|
); |
|
|
expect(result).toBe("Async result: test"); |
|
|
}); |
|
|
|
|
|
it("should handle function that returns complex objects", async () => { |
|
|
const complexFunction = () => ({ |
|
|
status: "success", |
|
|
data: { items: [1, 2, 3], metadata: { count: 3 } }, |
|
|
timestamp: "2024-01-15T10:30:00Z", |
|
|
}); |
|
|
const availableFunctions = { complex_func: complexFunction }; |
|
|
|
|
|
const toolCall: ToolCall = { |
|
|
id: "call_1", |
|
|
type: "function", |
|
|
function: { |
|
|
name: "complex_func", |
|
|
arguments: "{}", |
|
|
}, |
|
|
}; |
|
|
|
|
|
const result = await toolService.executeFunctionCall( |
|
|
toolCall, |
|
|
availableFunctions |
|
|
); |
|
|
const parsed = JSON.parse(result); |
|
|
expect(parsed.status).toBe("success"); |
|
|
expect(parsed.data.items).toEqual([1, 2, 3]); |
|
|
expect(parsed.data.metadata.count).toBe(3); |
|
|
}); |
|
|
|
|
|
it("should handle tools with no parameters", () => { |
|
|
const tools: ToolDefinition[] = [ |
|
|
{ |
|
|
type: "function", |
|
|
function: { |
|
|
name: "simple_function", |
|
|
description: "A function with no parameters", |
|
|
}, |
|
|
}, |
|
|
]; |
|
|
|
|
|
const result = toolService.validateTools(tools); |
|
|
expect(result.valid).toBe(true); |
|
|
expect(result.errors).toHaveLength(0); |
|
|
}); |
|
|
|
|
|
it("should handle tools with complex parameter schemas", () => { |
|
|
const tools: ToolDefinition[] = [ |
|
|
{ |
|
|
type: "function", |
|
|
function: { |
|
|
name: "complex_function", |
|
|
description: "A function with complex parameters", |
|
|
parameters: { |
|
|
type: "object", |
|
|
properties: { |
|
|
nested: { |
|
|
type: "object", |
|
|
properties: { |
|
|
value: { type: "string" }, |
|
|
count: { type: "number" }, |
|
|
}, |
|
|
required: ["value"], |
|
|
}, |
|
|
array_param: { |
|
|
type: "array", |
|
|
items: { type: "string" }, |
|
|
}, |
|
|
}, |
|
|
required: ["nested"], |
|
|
}, |
|
|
}, |
|
|
}, |
|
|
]; |
|
|
|
|
|
const result = toolService.validateTools(tools); |
|
|
expect(result.valid).toBe(true); |
|
|
expect(result.errors).toHaveLength(0); |
|
|
}); |
|
|
|
|
|
it("should handle extractFunctionCallsFromText fallback method", () => { |
|
|
|
|
|
const malformedResponse = ` |
|
|
Some text before |
|
|
"function": {"name": "test_func", "arguments": "{\\"param\\": \\"value\\"}"} |
|
|
Some text after |
|
|
`; |
|
|
|
|
|
const calls = toolService.extractFunctionCalls(malformedResponse); |
|
|
|
|
|
|
|
|
expect(calls).toHaveLength(0); |
|
|
}); |
|
|
|
|
|
it("should handle function execution with null/undefined arguments", async () => { |
|
|
const nullFunction = (args: any) => `Received: ${JSON.stringify(args)}`; |
|
|
const availableFunctions = { null_test: nullFunction }; |
|
|
|
|
|
const toolCall: ToolCall = { |
|
|
id: "call_1", |
|
|
type: "function", |
|
|
function: { |
|
|
name: "null_test", |
|
|
arguments: "null", |
|
|
}, |
|
|
}; |
|
|
|
|
|
const result = await toolService.executeFunctionCall( |
|
|
toolCall, |
|
|
availableFunctions |
|
|
); |
|
|
expect(result).toBe("Received: null"); |
|
|
}); |
|
|
|
|
|
|
|
|
it("should handle empty function arguments", async () => { |
|
|
const emptyArgsFunction = (args: any) => `Args: ${JSON.stringify(args)}`; |
|
|
const availableFunctions = { empty_args: emptyArgsFunction }; |
|
|
|
|
|
const toolCall: ToolCall = { |
|
|
id: "call_1", |
|
|
type: "function", |
|
|
function: { |
|
|
name: "empty_args", |
|
|
arguments: "", |
|
|
}, |
|
|
}; |
|
|
|
|
|
const result = await toolService.executeFunctionCall( |
|
|
toolCall, |
|
|
availableFunctions |
|
|
); |
|
|
const parsed = JSON.parse(result); |
|
|
expect(parsed.error).toContain("Error executing function"); |
|
|
}); |
|
|
|
|
|
it("should handle function that throws non-Error objects", async () => { |
|
|
const throwStringFunction = () => { |
|
|
throw "String error"; |
|
|
}; |
|
|
const availableFunctions = { throw_string: throwStringFunction }; |
|
|
|
|
|
const toolCall: ToolCall = { |
|
|
id: "call_1", |
|
|
type: "function", |
|
|
function: { |
|
|
name: "throw_string", |
|
|
arguments: "{}", |
|
|
}, |
|
|
}; |
|
|
|
|
|
const result = await toolService.executeFunctionCall( |
|
|
toolCall, |
|
|
availableFunctions |
|
|
); |
|
|
const parsed = JSON.parse(result); |
|
|
expect(parsed.error).toContain("Unknown error"); |
|
|
}); |
|
|
|
|
|
it("should handle very large function responses", async () => { |
|
|
const largeResponseFunction = () => { |
|
|
return { data: "x".repeat(10000), size: "large" }; |
|
|
}; |
|
|
const availableFunctions = { large_response: largeResponseFunction }; |
|
|
|
|
|
const toolCall: ToolCall = { |
|
|
id: "call_1", |
|
|
type: "function", |
|
|
function: { |
|
|
name: "large_response", |
|
|
arguments: "{}", |
|
|
}, |
|
|
}; |
|
|
|
|
|
const result = await toolService.executeFunctionCall( |
|
|
toolCall, |
|
|
availableFunctions |
|
|
); |
|
|
const parsed = JSON.parse(result); |
|
|
expect(parsed.size).toBe("large"); |
|
|
expect(parsed.data.length).toBe(10000); |
|
|
}); |
|
|
|
|
|
it("should handle function calls with special characters in arguments", async () => { |
|
|
const specialCharsFunction = (args: any) => `Received: ${args.text}`; |
|
|
const availableFunctions = { special_chars: specialCharsFunction }; |
|
|
|
|
|
const toolCall: ToolCall = { |
|
|
id: "call_1", |
|
|
type: "function", |
|
|
function: { |
|
|
name: "special_chars", |
|
|
arguments: '{"text": "Hello\\nWorld\\t\\"Quote\\""}', |
|
|
}, |
|
|
}; |
|
|
|
|
|
const result = await toolService.executeFunctionCall( |
|
|
toolCall, |
|
|
availableFunctions |
|
|
); |
|
|
expect(result).toBe('Received: Hello\nWorld\t"Quote"'); |
|
|
}); |
|
|
|
|
|
it("should handle deeply nested function arguments", async () => { |
|
|
const nestedFunction = (args: any) => args.level1.level2.level3.value; |
|
|
const availableFunctions = { nested_func: nestedFunction }; |
|
|
|
|
|
const toolCall: ToolCall = { |
|
|
id: "call_1", |
|
|
type: "function", |
|
|
function: { |
|
|
name: "nested_func", |
|
|
arguments: JSON.stringify({ |
|
|
level1: { |
|
|
level2: { |
|
|
level3: { |
|
|
value: "deep_value", |
|
|
}, |
|
|
}, |
|
|
}, |
|
|
}), |
|
|
}, |
|
|
}; |
|
|
|
|
|
const result = await toolService.executeFunctionCall( |
|
|
toolCall, |
|
|
availableFunctions |
|
|
); |
|
|
expect(result).toBe("deep_value"); |
|
|
}); |
|
|
}); |
|
|
}); |
|
|
|