| 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"); |
| }); |
| }); |
| }); |
|
|