diff --git a/backend/pkg/providers/bedrock/bedrock_test.go b/backend/pkg/providers/bedrock/bedrock_test.go index 0f36b59..801c659 100644 --- a/backend/pkg/providers/bedrock/bedrock_test.go +++ b/backend/pkg/providers/bedrock/bedrock_test.go @@ -733,6 +733,120 @@ func TestRestoreMissedToolsFromChain(t *testing.T) { }) } +// TestToolConfigPresentWhenChainContainsToolBlocks verifies the fix for issue #160: +// the Bedrock Converse API requires toolConfig whenever the message history contains +// toolUse or toolResult content blocks, even when the current turn provides no tools. +func TestToolConfigPresentWhenChainContainsToolBlocks(t *testing.T) { + // Simulate a conversation chain that contains tool calls and responses + // but where the current turn provides NO explicit tools. + chainWithToolBlocks := []llms.MessageContent{ + llms.TextParts(llms.ChatMessageTypeHuman, "scan port 80"), + { + Role: llms.ChatMessageTypeAI, + Parts: []llms.ContentPart{ + llms.ToolCall{ + ID: "call_001", + Type: "function", + FunctionCall: &llms.FunctionCall{ + Name: "nmap_scan", + Arguments: `{"target":"10.0.0.1","port":"80"}`, + }, + }, + }, + }, + { + Role: llms.ChatMessageTypeTool, + Parts: []llms.ContentPart{ + llms.ToolCallResponse{ + ToolCallID: "call_001", + Name: "nmap_scan", + Content: "port 80 open", + }, + }, + }, + llms.TextParts(llms.ChatMessageTypeAI, "Port 80 is open."), + llms.TextParts(llms.ChatMessageTypeHuman, "now check port 443"), + } + + t.Run("CallWithTools with nil tools restores from chain", func(t *testing.T) { + restored := restoreMissedToolsFromChain(chainWithToolBlocks, nil) + if len(restored) == 0 { + t.Fatal("expected non-empty tools after restoring from chain with toolUse/toolResult blocks") + } + + found := false + for _, tool := range restored { + if tool.Function != nil && tool.Function.Name == "nmap_scan" { + found = true + // Verify schema was inferred from the arguments + schema, ok := tool.Function.Parameters.(map[string]any) + if !ok { + t.Fatal("expected inferred schema") + } + props, ok := schema["properties"].(map[string]any) + if !ok { + t.Fatal("expected properties in inferred schema") + } + if _, exists := props["target"]; !exists { + t.Error("expected 'target' property in inferred schema") + } + if _, exists := props["port"]; !exists { + t.Error("expected 'port' property in inferred schema") + } + break + } + } + if !found { + t.Error("expected nmap_scan tool to be restored from chain") + } + }) + + t.Run("CallWithTools with empty tools restores from chain", func(t *testing.T) { + restored := restoreMissedToolsFromChain(chainWithToolBlocks, []llms.Tool{}) + if len(restored) == 0 { + t.Fatal("expected non-empty tools when passing empty slice with tool-containing chain") + } + }) + + t.Run("CallEx option building restores tools from chain", func(t *testing.T) { + // Simulate CallEx option building: extract tools from options (none), + // then restore from chain. + options := []llms.CallOption{ + llms.WithTemperature(0.7), + llms.WithMaxTokens(1000), + } + tools := extractToolsFromOptions(options) + tools = restoreMissedToolsFromChain(chainWithToolBlocks, tools) + if len(tools) == 0 { + t.Fatal("expected tools to be restored from chain in CallEx flow") + } + }) + + t.Run("chain with only ToolCallResponse restores tools", func(t *testing.T) { + // Edge case: chain has tool results but no tool calls (e.g., truncated history) + chainOnlyResults := []llms.MessageContent{ + { + Role: llms.ChatMessageTypeTool, + Parts: []llms.ContentPart{ + llms.ToolCallResponse{ + ToolCallID: "call_099", + Name: "curl_request", + Content: "HTTP 200 OK", + }, + }, + }, + } + + restored := restoreMissedToolsFromChain(chainOnlyResults, nil) + if len(restored) == 0 { + t.Fatal("expected tool restored from ToolCallResponse-only chain") + } + if restored[0].Function == nil || restored[0].Function.Name != "curl_request" { + t.Error("expected curl_request tool definition") + } + }) +} + // TestExtractToolsFromOptions verifies tool extraction from CallOptions. func TestExtractToolsFromOptions(t *testing.T) { t.Run("empty options returns nil", func(t *testing.T) {