first commit
This commit is contained in:
922
core/mcp/agent_test.go
Normal file
922
core/mcp/agent_test.go
Normal file
@@ -0,0 +1,922 @@
|
||||
package mcp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/maximhq/bifrost/core/schemas"
|
||||
)
|
||||
|
||||
// MockLLMCaller implements schemas.BifrostLLMCaller for testing
|
||||
type MockLLMCaller struct {
|
||||
chatResponses []*schemas.BifrostChatResponse
|
||||
responsesResponses []*schemas.BifrostResponsesResponse
|
||||
chatCallCount int
|
||||
responsesCallCount int
|
||||
}
|
||||
|
||||
func (m *MockLLMCaller) ChatCompletionRequest(ctx context.Context, req *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) {
|
||||
if m.chatCallCount >= len(m.chatResponses) {
|
||||
return nil, &schemas.BifrostError{
|
||||
IsBifrostError: false,
|
||||
Error: &schemas.ErrorField{
|
||||
Message: "no more mock chat responses available",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response := m.chatResponses[m.chatCallCount]
|
||||
m.chatCallCount++
|
||||
return response, nil
|
||||
}
|
||||
|
||||
func (m *MockLLMCaller) ResponsesRequest(ctx context.Context, req *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) {
|
||||
if m.responsesCallCount >= len(m.responsesResponses) {
|
||||
return nil, &schemas.BifrostError{
|
||||
IsBifrostError: false,
|
||||
Error: &schemas.ErrorField{
|
||||
Message: "no more mock responses api responses available",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
response := m.responsesResponses[m.responsesCallCount]
|
||||
m.responsesCallCount++
|
||||
return response, nil
|
||||
}
|
||||
|
||||
// MockLogger implements schemas.Logger for testing
|
||||
type MockLogger struct{}
|
||||
|
||||
func (m *MockLogger) Debug(msg string, args ...any) {}
|
||||
func (m *MockLogger) Info(msg string, args ...any) {}
|
||||
func (m *MockLogger) Warn(msg string, args ...any) {}
|
||||
func (m *MockLogger) Error(msg string, args ...any) {}
|
||||
func (m *MockLogger) Fatal(msg string, args ...any) {}
|
||||
func (m *MockLogger) SetLevel(level schemas.LogLevel) {}
|
||||
func (m *MockLogger) SetOutputType(outputType schemas.LoggerOutputType) {}
|
||||
func (m *MockLogger) LogHTTPRequest(level schemas.LogLevel, msg string) schemas.LogEventBuilder {
|
||||
return schemas.NoopLogEvent
|
||||
}
|
||||
|
||||
// MockClientManager implements ClientManager for testing
|
||||
type MockClientManager struct{}
|
||||
|
||||
func (m *MockClientManager) GetClientForTool(toolName string) *schemas.MCPClientState {
|
||||
return nil // Return nil to simulate no client found
|
||||
}
|
||||
|
||||
func (m *MockClientManager) GetClientByName(clientName string) *schemas.MCPClientState {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockClientManager) GetToolPerClient(ctx context.Context) map[string][]schemas.ChatTool {
|
||||
return make(map[string][]schemas.ChatTool)
|
||||
}
|
||||
|
||||
func TestHasToolCallsForChatResponse(t *testing.T) {
|
||||
// Test nil response
|
||||
if hasToolCallsForChatResponse(nil) {
|
||||
t.Error("Should return false for nil response")
|
||||
}
|
||||
|
||||
// Test empty choices
|
||||
emptyResponse := &schemas.BifrostChatResponse{
|
||||
Choices: []schemas.BifrostResponseChoice{},
|
||||
}
|
||||
if hasToolCallsForChatResponse(emptyResponse) {
|
||||
t.Error("Should return false for response with empty choices")
|
||||
}
|
||||
|
||||
// Test response with tool_calls finish reason
|
||||
toolCallsResponse := &schemas.BifrostChatResponse{
|
||||
Choices: []schemas.BifrostResponseChoice{
|
||||
{
|
||||
FinishReason: schemas.Ptr("tool_calls"),
|
||||
},
|
||||
},
|
||||
}
|
||||
if !hasToolCallsForChatResponse(toolCallsResponse) {
|
||||
t.Error("Should return true for response with tool_calls finish reason")
|
||||
}
|
||||
|
||||
// Test response with actual tool calls
|
||||
responseWithToolCalls := &schemas.BifrostChatResponse{
|
||||
Choices: []schemas.BifrostResponseChoice{
|
||||
{
|
||||
ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{
|
||||
Message: &schemas.ChatMessage{
|
||||
ChatAssistantMessage: &schemas.ChatAssistantMessage{
|
||||
ToolCalls: []schemas.ChatAssistantMessageToolCall{
|
||||
{
|
||||
Function: schemas.ChatAssistantMessageToolCallFunction{
|
||||
Name: schemas.Ptr("test_tool"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if !hasToolCallsForChatResponse(responseWithToolCalls) {
|
||||
t.Error("Should return true for response with tool calls in message")
|
||||
}
|
||||
|
||||
// Test response with stop finish reason AND tool calls — should return true.
|
||||
// Some providers (e.g. Gemini) use "stop" even when returning tool calls, so
|
||||
// finish_reason alone is not sufficient to determine whether tool calls are present.
|
||||
responseWithStopReason := &schemas.BifrostChatResponse{
|
||||
Choices: []schemas.BifrostResponseChoice{
|
||||
{
|
||||
FinishReason: schemas.Ptr("stop"),
|
||||
ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{
|
||||
Message: &schemas.ChatMessage{
|
||||
ChatAssistantMessage: &schemas.ChatAssistantMessage{
|
||||
ToolCalls: []schemas.ChatAssistantMessageToolCall{
|
||||
{
|
||||
Function: schemas.ChatAssistantMessageToolCallFunction{
|
||||
Name: schemas.Ptr("test_tool"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if !hasToolCallsForChatResponse(responseWithStopReason) {
|
||||
t.Error("Should return true for response with tool calls even when finish_reason is stop")
|
||||
}
|
||||
|
||||
// Test response with stop finish reason and NO tool calls — should return false.
|
||||
responseWithStopNoTools := &schemas.BifrostChatResponse{
|
||||
Choices: []schemas.BifrostResponseChoice{
|
||||
{
|
||||
FinishReason: schemas.Ptr("stop"),
|
||||
ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{
|
||||
Message: &schemas.ChatMessage{},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if hasToolCallsForChatResponse(responseWithStopNoTools) {
|
||||
t.Error("Should return false for response with stop finish reason and no tool calls")
|
||||
}
|
||||
|
||||
// Test response where tool calls are in a non-first choice (Responses API conversion scenario).
|
||||
// ToBifrostChatResponse() splits text and tool calls across separate choices when a model
|
||||
// returns both text content and tool calls (e.g. Claude via the /v1/responses endpoint).
|
||||
responseWithToolCallsInSecondChoice := &schemas.BifrostChatResponse{
|
||||
Choices: []schemas.BifrostResponseChoice{
|
||||
{
|
||||
// First choice: text message only
|
||||
ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{
|
||||
Message: &schemas.ChatMessage{},
|
||||
},
|
||||
},
|
||||
{
|
||||
// Second choice: tool calls
|
||||
ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{
|
||||
Message: &schemas.ChatMessage{
|
||||
ChatAssistantMessage: &schemas.ChatAssistantMessage{
|
||||
ToolCalls: []schemas.ChatAssistantMessageToolCall{
|
||||
{
|
||||
Function: schemas.ChatAssistantMessageToolCallFunction{
|
||||
Name: schemas.Ptr("youtube_search"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if !hasToolCallsForChatResponse(responseWithToolCallsInSecondChoice) {
|
||||
t.Error("Should return true when tool calls appear in a non-first choice (Responses API conversion)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractToolCalls(t *testing.T) {
|
||||
// Test response without tool calls
|
||||
responseNoTools := &schemas.BifrostChatResponse{
|
||||
Choices: []schemas.BifrostResponseChoice{
|
||||
{
|
||||
FinishReason: schemas.Ptr("stop"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
toolCalls := extractToolCalls(responseNoTools)
|
||||
if len(toolCalls) != 0 {
|
||||
t.Error("Should return empty slice for response without tool calls")
|
||||
}
|
||||
|
||||
// Test response with tool calls
|
||||
expectedToolCalls := []schemas.ChatAssistantMessageToolCall{
|
||||
{
|
||||
ID: schemas.Ptr("call_123"),
|
||||
Function: schemas.ChatAssistantMessageToolCallFunction{
|
||||
Name: schemas.Ptr("test_tool"),
|
||||
Arguments: `{"param": "value"}`,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
responseWithTools := &schemas.BifrostChatResponse{
|
||||
Choices: []schemas.BifrostResponseChoice{
|
||||
{
|
||||
ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{
|
||||
Message: &schemas.ChatMessage{
|
||||
ChatAssistantMessage: &schemas.ChatAssistantMessage{
|
||||
ToolCalls: expectedToolCalls,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
actualToolCalls := extractToolCalls(responseWithTools)
|
||||
if len(actualToolCalls) != 1 {
|
||||
t.Errorf("Expected 1 tool call, got %d", len(actualToolCalls))
|
||||
}
|
||||
|
||||
if actualToolCalls[0].Function.Name == nil || *actualToolCalls[0].Function.Name != "test_tool" {
|
||||
t.Error("Tool call name mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteAgentForChatRequest(t *testing.T) {
|
||||
// Test with response that has no tool calls - should return immediately
|
||||
responseNoTools := &schemas.BifrostChatResponse{
|
||||
Choices: []schemas.BifrostResponseChoice{
|
||||
{
|
||||
FinishReason: schemas.Ptr("stop"),
|
||||
ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{
|
||||
Message: &schemas.ChatMessage{
|
||||
Role: schemas.ChatMessageRoleAssistant,
|
||||
Content: &schemas.ChatMessageContent{
|
||||
ContentStr: schemas.Ptr("Hello, how can I help you?"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
llmCaller := &MockLLMCaller{}
|
||||
makeReq := func(ctx *schemas.BifrostContext, req *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) {
|
||||
return llmCaller.ChatCompletionRequest(ctx, req)
|
||||
}
|
||||
originalReq := &schemas.BifrostChatRequest{
|
||||
Provider: schemas.OpenAI,
|
||||
Model: "gpt-4",
|
||||
Input: []schemas.ChatMessage{
|
||||
{
|
||||
Role: schemas.ChatMessageRoleUser,
|
||||
Content: &schemas.ChatMessageContent{
|
||||
ContentStr: schemas.Ptr("Hello"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
|
||||
agentModeExecutor := &AgentModeExecutor{
|
||||
logger: &MockLogger{},
|
||||
}
|
||||
result, err := agentModeExecutor.ExecuteAgentForChatRequest(ctx, 10, originalReq, responseNoTools, makeReq, nil, nil, &MockClientManager{})
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for response without tool calls, got: %v", err)
|
||||
}
|
||||
if result != responseNoTools {
|
||||
t.Error("Expected same response to be returned for response without tool calls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteAgentForChatRequest_WithNonAutoExecutableTools(t *testing.T) {
|
||||
|
||||
// Create a response with tool calls that will NOT be auto-executed
|
||||
responseWithNonAutoTools := &schemas.BifrostChatResponse{
|
||||
Choices: []schemas.BifrostResponseChoice{
|
||||
{
|
||||
FinishReason: schemas.Ptr("tool_calls"),
|
||||
ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{
|
||||
Message: &schemas.ChatMessage{
|
||||
Role: schemas.ChatMessageRoleAssistant,
|
||||
Content: &schemas.ChatMessageContent{
|
||||
ContentStr: schemas.Ptr("I need to call a tool"),
|
||||
},
|
||||
ChatAssistantMessage: &schemas.ChatAssistantMessage{
|
||||
ToolCalls: []schemas.ChatAssistantMessageToolCall{
|
||||
{
|
||||
ID: schemas.Ptr("call_123"),
|
||||
Function: schemas.ChatAssistantMessageToolCallFunction{
|
||||
Name: schemas.Ptr("non_auto_executable_tool"),
|
||||
Arguments: `{"param": "value"}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
llmCaller := &MockLLMCaller{}
|
||||
makeReq := func(ctx *schemas.BifrostContext, req *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) {
|
||||
return llmCaller.ChatCompletionRequest(ctx, req)
|
||||
}
|
||||
originalReq := &schemas.BifrostChatRequest{
|
||||
Provider: schemas.OpenAI,
|
||||
Model: "gpt-4",
|
||||
Input: []schemas.ChatMessage{
|
||||
{
|
||||
Role: schemas.ChatMessageRoleUser,
|
||||
Content: &schemas.ChatMessageContent{
|
||||
ContentStr: schemas.Ptr("Test message"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
|
||||
agentModeExecutor := &AgentModeExecutor{
|
||||
logger: &MockLogger{},
|
||||
}
|
||||
// Execute agent mode - should return immediately with non-auto-executable tools
|
||||
result, err := agentModeExecutor.ExecuteAgentForChatRequest(ctx, 10, originalReq, responseWithNonAutoTools, makeReq, nil, nil, &MockClientManager{})
|
||||
|
||||
// Should not return error for non-auto-executable tools
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for non-auto-executable tools, got: %v", err)
|
||||
}
|
||||
|
||||
// Should return a response with the non-auto-executable tool calls
|
||||
if result == nil {
|
||||
t.Error("Expected result to be returned for non-auto-executable tools")
|
||||
}
|
||||
|
||||
// Verify that no LLM calls were made (since tools are non-auto-executable)
|
||||
if llmCaller.chatCallCount != 0 {
|
||||
t.Errorf("Expected 0 LLM calls for non-auto-executable tools, got %d", llmCaller.chatCallCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHasToolCallsForResponsesResponse(t *testing.T) {
|
||||
// Test nil response
|
||||
if hasToolCallsForResponsesResponse(nil) {
|
||||
t.Error("Should return false for nil response")
|
||||
}
|
||||
|
||||
// Test empty output
|
||||
emptyResponse := &schemas.BifrostResponsesResponse{
|
||||
Output: []schemas.ResponsesMessage{},
|
||||
}
|
||||
if hasToolCallsForResponsesResponse(emptyResponse) {
|
||||
t.Error("Should return false for response with empty output")
|
||||
}
|
||||
|
||||
// Test response with function call
|
||||
responseWithFunctionCall := &schemas.BifrostResponsesResponse{
|
||||
Output: []schemas.ResponsesMessage{
|
||||
{
|
||||
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
|
||||
ResponsesToolMessage: &schemas.ResponsesToolMessage{
|
||||
CallID: schemas.Ptr("call_123"),
|
||||
Name: schemas.Ptr("test_tool"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if !hasToolCallsForResponsesResponse(responseWithFunctionCall) {
|
||||
t.Error("Should return true for response with function call")
|
||||
}
|
||||
|
||||
// Test response with function call but no ResponsesToolMessage
|
||||
responseWithoutToolMessage := &schemas.BifrostResponsesResponse{
|
||||
Output: []schemas.ResponsesMessage{
|
||||
{
|
||||
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
|
||||
// No ResponsesToolMessage
|
||||
},
|
||||
},
|
||||
}
|
||||
if hasToolCallsForResponsesResponse(responseWithoutToolMessage) {
|
||||
t.Error("Should return false for response with function call type but no ResponsesToolMessage")
|
||||
}
|
||||
|
||||
// Test response with regular message
|
||||
responseWithRegularMessage := &schemas.BifrostResponsesResponse{
|
||||
Output: []schemas.ResponsesMessage{
|
||||
{
|
||||
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
|
||||
Content: &schemas.ResponsesMessageContent{
|
||||
ContentStr: schemas.Ptr("Hello"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if hasToolCallsForResponsesResponse(responseWithRegularMessage) {
|
||||
t.Error("Should return false for response with regular message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteAgentForResponsesRequest(t *testing.T) {
|
||||
|
||||
// Test with response that has no tool calls - should return immediately
|
||||
responseNoTools := &schemas.BifrostResponsesResponse{
|
||||
Output: []schemas.ResponsesMessage{
|
||||
{
|
||||
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
|
||||
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
|
||||
Content: &schemas.ResponsesMessageContent{
|
||||
ContentStr: schemas.Ptr("Hello, how can I help you?"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
llmCaller := &MockLLMCaller{}
|
||||
makeReq := func(ctx *schemas.BifrostContext, req *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) {
|
||||
return llmCaller.ResponsesRequest(ctx, req)
|
||||
}
|
||||
originalReq := &schemas.BifrostResponsesRequest{
|
||||
Provider: schemas.OpenAI,
|
||||
Model: "gpt-4",
|
||||
Input: []schemas.ResponsesMessage{
|
||||
{
|
||||
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
|
||||
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
|
||||
Content: &schemas.ResponsesMessageContent{
|
||||
ContentStr: schemas.Ptr("Hello"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
|
||||
agentModeExecutor := &AgentModeExecutor{
|
||||
logger: &MockLogger{},
|
||||
}
|
||||
result, err := agentModeExecutor.ExecuteAgentForResponsesRequest(ctx, 10, originalReq, responseNoTools, makeReq, nil, nil, &MockClientManager{})
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for response without tool calls, got: %v", err)
|
||||
}
|
||||
if result != responseNoTools {
|
||||
t.Error("Expected same response to be returned for response without tool calls")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteAgentForResponsesRequest_WithNonAutoExecutableTools(t *testing.T) {
|
||||
|
||||
// Create a response with tool calls that will NOT be auto-executed
|
||||
responseWithNonAutoTools := &schemas.BifrostResponsesResponse{
|
||||
Output: []schemas.ResponsesMessage{
|
||||
{
|
||||
Type: schemas.Ptr(schemas.ResponsesMessageTypeFunctionCall),
|
||||
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleAssistant),
|
||||
ResponsesToolMessage: &schemas.ResponsesToolMessage{
|
||||
CallID: schemas.Ptr("call_123"),
|
||||
Name: schemas.Ptr("non_auto_executable_tool"),
|
||||
Arguments: schemas.Ptr(`{"param": "value"}`),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
llmCaller := &MockLLMCaller{}
|
||||
makeReq := func(ctx *schemas.BifrostContext, req *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) {
|
||||
return llmCaller.ResponsesRequest(ctx, req)
|
||||
}
|
||||
originalReq := &schemas.BifrostResponsesRequest{
|
||||
Provider: schemas.OpenAI,
|
||||
Model: "gpt-4",
|
||||
Input: []schemas.ResponsesMessage{
|
||||
{
|
||||
Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage),
|
||||
Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser),
|
||||
Content: &schemas.ResponsesMessageContent{
|
||||
ContentStr: schemas.Ptr("Test message"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
|
||||
agentModeExecutor := &AgentModeExecutor{
|
||||
logger: &MockLogger{},
|
||||
}
|
||||
// Execute agent mode - should return immediately with non-auto-executable tools
|
||||
result, err := agentModeExecutor.ExecuteAgentForResponsesRequest(ctx, 10, originalReq, responseWithNonAutoTools, makeReq, nil, nil, &MockClientManager{})
|
||||
|
||||
// Should not return error for non-auto-executable tools
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error for non-auto-executable tools, got: %v", err)
|
||||
}
|
||||
|
||||
// Should return a response with the non-auto-executable tool calls
|
||||
if result == nil {
|
||||
t.Error("Expected result to be returned for non-auto-executable tools")
|
||||
}
|
||||
|
||||
// Verify that no LLM calls were made (since tools are non-auto-executable)
|
||||
if llmCaller.responsesCallCount != 0 {
|
||||
t.Errorf("Expected 0 LLM calls for non-auto-executable tools, got %d", llmCaller.responsesCallCount)
|
||||
}
|
||||
}
|
||||
|
||||
// MockAutoClientManager returns a client state that marks all tools as auto-executable.
|
||||
type MockAutoClientManager struct{}
|
||||
|
||||
func (m *MockAutoClientManager) GetClientForTool(toolName string) *schemas.MCPClientState {
|
||||
return &schemas.MCPClientState{
|
||||
Name: "test-client",
|
||||
ExecutionConfig: &schemas.MCPClientConfig{
|
||||
Name: "test-client",
|
||||
ToolsToExecute: []string{"*"},
|
||||
ToolsToAutoExecute: []string{"*"},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockAutoClientManager) GetClientByName(clientName string) *schemas.MCPClientState {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockAutoClientManager) GetToolPerClient(ctx context.Context) map[string][]schemas.ChatTool {
|
||||
return make(map[string][]schemas.ChatTool)
|
||||
}
|
||||
|
||||
// TestParallelToolCallsHaveUniqueMCPLogIDs verifies that parallel tool calls within a
|
||||
// single LLM response each receive a unique BifrostContextKeyMCPLogID in their context.
|
||||
//
|
||||
// The logging plugin uses this ID as the primary key for MCPToolLog entries, so each
|
||||
// parallel tool call must have a distinct value to avoid PK conflicts and input/output
|
||||
// mismatches caused by multiple goroutines racing to update the same row.
|
||||
func TestParallelToolCallsHaveUniqueMCPLogIDs(t *testing.T) {
|
||||
const requestID = "test-request-id-123"
|
||||
const numTools = 4
|
||||
|
||||
// Collect the MCP log IDs seen by executeToolFunc across all parallel calls.
|
||||
var mu sync.Mutex
|
||||
seenMCPLogIDs := make([]string, 0, numTools)
|
||||
|
||||
// Build a response with 4 parallel is_prime tool calls.
|
||||
toolCalls := make([]schemas.ChatAssistantMessageToolCall, numTools)
|
||||
for i := range toolCalls {
|
||||
id := fmt.Sprintf("call_%d", i)
|
||||
name := "is_prime"
|
||||
toolCalls[i] = schemas.ChatAssistantMessageToolCall{
|
||||
ID: &id,
|
||||
Function: schemas.ChatAssistantMessageToolCallFunction{
|
||||
Name: &name,
|
||||
Arguments: fmt.Sprintf(`{"n": %d}`, i+2),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
initialResponse := &schemas.BifrostChatResponse{
|
||||
Choices: []schemas.BifrostResponseChoice{
|
||||
{
|
||||
FinishReason: schemas.Ptr("tool_calls"),
|
||||
ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{
|
||||
Message: &schemas.ChatMessage{
|
||||
Role: schemas.ChatMessageRoleAssistant,
|
||||
ChatAssistantMessage: &schemas.ChatAssistantMessage{
|
||||
ToolCalls: toolCalls,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// makeReq returns a final non-tool response to terminate the agent loop.
|
||||
makeReq := func(ctx *schemas.BifrostContext, req *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) {
|
||||
return &schemas.BifrostChatResponse{
|
||||
Choices: []schemas.BifrostResponseChoice{
|
||||
{
|
||||
FinishReason: schemas.Ptr("stop"),
|
||||
ChatNonStreamResponseChoice: &schemas.ChatNonStreamResponseChoice{
|
||||
Message: &schemas.ChatMessage{
|
||||
Role: schemas.ChatMessageRoleAssistant,
|
||||
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("2, 3, and 5 are prime; 4 is not.")},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
executeToolFunc := func(ctx *schemas.BifrostContext, req *schemas.BifrostMCPRequest) (*schemas.BifrostMCPResponse, error) {
|
||||
mcpLogID, ok := ctx.Value(schemas.BifrostContextKeyMCPLogID).(string)
|
||||
if !ok || mcpLogID == "" {
|
||||
return nil, fmt.Errorf("missing mcp log id in tool context")
|
||||
}
|
||||
mu.Lock()
|
||||
seenMCPLogIDs = append(seenMCPLogIDs, mcpLogID)
|
||||
mu.Unlock()
|
||||
|
||||
toolCallID := ""
|
||||
if req.ChatAssistantMessageToolCall != nil && req.ChatAssistantMessageToolCall.ID != nil {
|
||||
toolCallID = *req.ChatAssistantMessageToolCall.ID
|
||||
}
|
||||
return &schemas.BifrostMCPResponse{
|
||||
ChatMessage: &schemas.ChatMessage{
|
||||
Role: schemas.ChatMessageRoleTool,
|
||||
ChatToolMessage: &schemas.ChatToolMessage{
|
||||
ToolCallID: &toolCallID,
|
||||
},
|
||||
Content: &schemas.ChatMessageContent{
|
||||
ContentStr: schemas.Ptr("true"),
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline)
|
||||
ctx.SetValue(schemas.BifrostContextKeyRequestID, requestID)
|
||||
|
||||
originalReq := &schemas.BifrostChatRequest{
|
||||
Provider: schemas.OpenAI,
|
||||
Model: "gpt-4",
|
||||
Input: []schemas.ChatMessage{
|
||||
{
|
||||
Role: schemas.ChatMessageRoleUser,
|
||||
Content: &schemas.ChatMessageContent{ContentStr: schemas.Ptr("check if 2,3,4,5 are prime")},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
agentModeExecutor := &AgentModeExecutor{logger: &MockLogger{}}
|
||||
_, err := agentModeExecutor.ExecuteAgentForChatRequest(
|
||||
ctx, 10, originalReq, initialResponse, makeReq, nil, executeToolFunc, &MockAutoClientManager{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if len(seenMCPLogIDs) != numTools {
|
||||
t.Fatalf("expected executeToolFunc to be called %d times, got %d", numTools, len(seenMCPLogIDs))
|
||||
}
|
||||
|
||||
// Each parallel tool call must have a unique MCP log ID so the logging plugin
|
||||
// can create separate MCPToolLog entries without primary key conflicts.
|
||||
uniqueIDs := make(map[string]struct{})
|
||||
for _, id := range seenMCPLogIDs {
|
||||
uniqueIDs[id] = struct{}{}
|
||||
}
|
||||
if len(uniqueIDs) != numTools {
|
||||
t.Errorf(
|
||||
"expected %d unique MCP log IDs (one per parallel tool call), got %d",
|
||||
numTools, len(uniqueIDs),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// CONVERTER TESTS (Phase 2)
|
||||
// ============================================================================
|
||||
|
||||
// TestResponsesToolMessageToChatAssistantMessageToolCall tests conversion of Responses tool message to Chat tool call
|
||||
func TestResponsesToolMessageToChatAssistantMessageToolCall(t *testing.T) {
|
||||
// Test with valid tool message
|
||||
responsesToolMsg := &schemas.ResponsesToolMessage{
|
||||
CallID: schemas.Ptr("call-123"),
|
||||
Name: schemas.Ptr("calculate"),
|
||||
Arguments: schemas.Ptr("{\"x\": 10, \"y\": 20}"),
|
||||
}
|
||||
|
||||
chatToolCall := responsesToolMsg.ToChatAssistantMessageToolCall()
|
||||
|
||||
if chatToolCall == nil {
|
||||
t.Fatal("Expected non-nil ChatAssistantMessageToolCall")
|
||||
}
|
||||
|
||||
if chatToolCall.Type == nil || *chatToolCall.Type != "function" {
|
||||
t.Errorf("Expected Type 'function', got %v", chatToolCall.Type)
|
||||
}
|
||||
|
||||
if chatToolCall.Function.Name == nil || *chatToolCall.Function.Name != "calculate" {
|
||||
t.Errorf("Expected Name 'calculate', got %v", chatToolCall.Function.Name)
|
||||
}
|
||||
|
||||
if chatToolCall.Function.Arguments != `{"x": 10, "y": 20}` {
|
||||
t.Errorf("Expected Arguments '{\"x\": 10, \"y\": 20}', got %s", chatToolCall.Function.Arguments)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResponsesToolMessageToChatAssistantMessageToolCall_Nil tests nil handling
|
||||
func TestResponsesToolMessageToChatAssistantMessageToolCall_Nil(t *testing.T) {
|
||||
responsesToolMsg := &schemas.ResponsesToolMessage{
|
||||
CallID: schemas.Ptr("call-123"),
|
||||
Name: schemas.Ptr("calculate"),
|
||||
Arguments: nil, // Test nil Arguments case
|
||||
}
|
||||
|
||||
chatToolCall := responsesToolMsg.ToChatAssistantMessageToolCall()
|
||||
if chatToolCall == nil {
|
||||
t.Fatal("Expected non-nil ChatAssistantMessageToolCall")
|
||||
}
|
||||
|
||||
// Assert that nil Arguments produces a valid empty JSON object
|
||||
if chatToolCall.Function.Arguments != "{}" {
|
||||
t.Errorf("Expected Arguments '{}' for nil input, got %q", chatToolCall.Function.Arguments)
|
||||
}
|
||||
|
||||
// Verify it's valid JSON by attempting to unmarshal
|
||||
var args map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(chatToolCall.Function.Arguments), &args); err != nil {
|
||||
t.Errorf("Expected valid JSON, but unmarshaling failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMessageToResponsesToolMessage tests conversion of Chat tool result to Responses tool message
|
||||
func TestChatMessageToResponsesToolMessage(t *testing.T) {
|
||||
// Test with valid chat tool message
|
||||
chatMsg := &schemas.ChatMessage{
|
||||
Role: schemas.ChatMessageRoleTool,
|
||||
ChatToolMessage: &schemas.ChatToolMessage{
|
||||
ToolCallID: schemas.Ptr("call-123"),
|
||||
},
|
||||
Content: &schemas.ChatMessageContent{
|
||||
ContentStr: schemas.Ptr("Result: 30"),
|
||||
},
|
||||
}
|
||||
|
||||
responsesMsg := chatMsg.ToResponsesToolMessage()
|
||||
|
||||
if responsesMsg == nil {
|
||||
t.Fatal("Expected non-nil ResponsesMessage")
|
||||
}
|
||||
|
||||
if responsesMsg.Type == nil || *responsesMsg.Type != schemas.ResponsesMessageTypeFunctionCallOutput {
|
||||
t.Errorf("Expected Type 'function_call_output', got %v", responsesMsg.Type)
|
||||
}
|
||||
|
||||
if responsesMsg.ResponsesToolMessage == nil {
|
||||
t.Fatal("Expected non-nil ResponsesToolMessage")
|
||||
}
|
||||
|
||||
if responsesMsg.ResponsesToolMessage.CallID == nil || *responsesMsg.ResponsesToolMessage.CallID != "call-123" {
|
||||
t.Errorf("Expected CallID 'call-123', got %v", responsesMsg.ResponsesToolMessage.CallID)
|
||||
}
|
||||
|
||||
if responsesMsg.ResponsesToolMessage.Output == nil {
|
||||
t.Fatal("Expected non-nil Output")
|
||||
}
|
||||
|
||||
if responsesMsg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr == nil {
|
||||
t.Fatal("Expected non-nil ResponsesToolCallOutputStr")
|
||||
}
|
||||
|
||||
if *responsesMsg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr != "Result: 30" {
|
||||
t.Errorf("Expected Output 'Result: 30', got %s", *responsesMsg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMessageToResponsesToolMessage_Nil tests nil handling
|
||||
func TestChatMessageToResponsesToolMessage_Nil(t *testing.T) {
|
||||
var chatMsg *schemas.ChatMessage
|
||||
|
||||
responsesMsg := chatMsg.ToResponsesToolMessage()
|
||||
|
||||
if responsesMsg != nil {
|
||||
t.Errorf("Expected nil for nil input, got %v", responsesMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// TestChatMessageToResponsesToolMessage_NoToolMessage tests with non-tool message
|
||||
func TestChatMessageToResponsesToolMessage_NoToolMessage(t *testing.T) {
|
||||
// Chat message without ChatToolMessage
|
||||
chatMsg := &schemas.ChatMessage{
|
||||
Role: schemas.ChatMessageRoleAssistant,
|
||||
}
|
||||
|
||||
responsesMsg := chatMsg.ToResponsesToolMessage()
|
||||
|
||||
if responsesMsg != nil {
|
||||
t.Errorf("Expected nil for non-tool message, got %v", responsesMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// RESPONSES API TOOL CONVERSION TESTS (Phase 3)
|
||||
// ============================================================================
|
||||
|
||||
// TestExecuteAgentForResponsesRequest_ConversionRoundTrip tests that tool calls survive format conversion
|
||||
// This is a unit test of the conversion logic only, not full agent execution
|
||||
func TestExecuteAgentForResponsesRequest_ConversionRoundTrip(t *testing.T) {
|
||||
// Create a tool message in Responses format
|
||||
responsesToolMsg := &schemas.ResponsesToolMessage{
|
||||
CallID: schemas.Ptr("call-456"),
|
||||
Name: schemas.Ptr("readToolFile"),
|
||||
Arguments: schemas.Ptr("{\"file\": \"test.txt\"}"),
|
||||
}
|
||||
|
||||
// Step 1: Convert Responses format to Chat format
|
||||
chatToolCall := responsesToolMsg.ToChatAssistantMessageToolCall()
|
||||
|
||||
if chatToolCall == nil {
|
||||
t.Fatal("Failed to convert Responses to Chat format")
|
||||
}
|
||||
|
||||
if *chatToolCall.ID != "call-456" {
|
||||
t.Errorf("ID lost in conversion: expected 'call-456', got %s", *chatToolCall.ID)
|
||||
}
|
||||
|
||||
if *chatToolCall.Function.Name != "readToolFile" {
|
||||
t.Errorf("Name lost in conversion: expected 'readToolFile', got %s", *chatToolCall.Function.Name)
|
||||
}
|
||||
|
||||
if chatToolCall.Function.Arguments != "{\"file\": \"test.txt\"}" {
|
||||
t.Errorf("Arguments lost in conversion: expected '%s', got %s",
|
||||
"{\"file\": \"test.txt\"}", chatToolCall.Function.Arguments)
|
||||
}
|
||||
|
||||
// Step 2: Simulate tool execution by creating a result message
|
||||
chatResultMsg := &schemas.ChatMessage{
|
||||
Role: schemas.ChatMessageRoleTool,
|
||||
ChatToolMessage: &schemas.ChatToolMessage{
|
||||
ToolCallID: chatToolCall.ID,
|
||||
},
|
||||
Content: &schemas.ChatMessageContent{
|
||||
ContentStr: schemas.Ptr("File contents here"),
|
||||
},
|
||||
}
|
||||
|
||||
// Step 3: Convert tool result back to Responses format
|
||||
responsesResultMsg := chatResultMsg.ToResponsesToolMessage()
|
||||
|
||||
if responsesResultMsg == nil {
|
||||
t.Fatal("Failed to convert Chat result to Responses format")
|
||||
}
|
||||
|
||||
if responsesResultMsg.ResponsesToolMessage.CallID == nil {
|
||||
t.Error("CallID lost in round-trip conversion")
|
||||
} else if *responsesResultMsg.ResponsesToolMessage.CallID != "call-456" {
|
||||
t.Errorf("CallID changed in round-trip: expected 'call-456', got %s", *responsesResultMsg.ResponsesToolMessage.CallID)
|
||||
}
|
||||
|
||||
// Verify output is preserved
|
||||
if responsesResultMsg.ResponsesToolMessage.Output == nil {
|
||||
t.Error("Output lost in conversion")
|
||||
} else if responsesResultMsg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr == nil {
|
||||
t.Error("Output content lost in conversion")
|
||||
} else if *responsesResultMsg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr != "File contents here" {
|
||||
t.Errorf("Output content changed: expected 'File contents here', got %s",
|
||||
*responsesResultMsg.ResponsesToolMessage.Output.ResponsesToolCallOutputStr)
|
||||
}
|
||||
|
||||
// Verify message type is correct
|
||||
if responsesResultMsg.Type == nil || *responsesResultMsg.Type != schemas.ResponsesMessageTypeFunctionCallOutput {
|
||||
t.Errorf("Expected message type 'function_call_output', got %v", responsesResultMsg.Type)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecuteAgentForResponsesRequest_OutputStructured tests conversion with structured output blocks
|
||||
func TestExecuteAgentForResponsesRequest_OutputStructured(t *testing.T) {
|
||||
chatResultMsg := &schemas.ChatMessage{
|
||||
Role: schemas.ChatMessageRoleTool,
|
||||
ChatToolMessage: &schemas.ChatToolMessage{
|
||||
ToolCallID: schemas.Ptr("call-789"),
|
||||
},
|
||||
Content: &schemas.ChatMessageContent{
|
||||
ContentBlocks: []schemas.ChatContentBlock{
|
||||
{
|
||||
Type: schemas.ChatContentBlockTypeText,
|
||||
Text: schemas.Ptr("Block 1"),
|
||||
},
|
||||
{
|
||||
Type: schemas.ChatContentBlockTypeText,
|
||||
Text: schemas.Ptr("Block 2"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
responsesMsg := chatResultMsg.ToResponsesToolMessage()
|
||||
|
||||
if responsesMsg == nil {
|
||||
t.Fatal("Expected non-nil ResponsesMessage for structured output")
|
||||
}
|
||||
|
||||
if responsesMsg.ResponsesToolMessage.Output == nil {
|
||||
t.Fatal("Expected non-nil Output for structured content")
|
||||
}
|
||||
|
||||
if responsesMsg.ResponsesToolMessage.Output.ResponsesFunctionToolCallOutputBlocks == nil {
|
||||
t.Error("Expected output blocks for structured content")
|
||||
} else if len(responsesMsg.ResponsesToolMessage.Output.ResponsesFunctionToolCallOutputBlocks) != 2 {
|
||||
t.Errorf("Expected 2 output blocks, got %d", len(responsesMsg.ResponsesToolMessage.Output.ResponsesFunctionToolCallOutputBlocks))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user