diff --git a/pkg/agent/loop_test.go b/pkg/agent/loop_test.go index a6604e87f..781a02d73 100644 --- a/pkg/agent/loop_test.go +++ b/pkg/agent/loop_test.go @@ -1318,3 +1318,78 @@ func TestResolveMediaRefs_MixedImageAndFile(t *testing.T) { t.Fatalf("expected content %q, got %q", expectedContent, result[0].Content) } } + +// --- Native search helper tests --- + +type nativeSearchProvider struct { + supported bool +} + +func (p *nativeSearchProvider) Chat(ctx context.Context, msgs []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]any) (*providers.LLMResponse, error) { + return &providers.LLMResponse{Content: "ok"}, nil +} + +func (p *nativeSearchProvider) GetDefaultModel() string { return "test-model" } + +func (p *nativeSearchProvider) SupportsNativeSearch() bool { return p.supported } + +type plainProvider struct{} + +func (p *plainProvider) Chat(ctx context.Context, msgs []providers.Message, tools []providers.ToolDefinition, model string, opts map[string]any) (*providers.LLMResponse, error) { + return &providers.LLMResponse{Content: "ok"}, nil +} + +func (p *plainProvider) GetDefaultModel() string { return "test-model" } + +func TestIsNativeSearchProvider_Supported(t *testing.T) { + if !isNativeSearchProvider(&nativeSearchProvider{supported: true}) { + t.Fatal("expected true for provider that supports native search") + } +} + +func TestIsNativeSearchProvider_NotSupported(t *testing.T) { + if isNativeSearchProvider(&nativeSearchProvider{supported: false}) { + t.Fatal("expected false for provider that does not support native search") + } +} + +func TestIsNativeSearchProvider_NoInterface(t *testing.T) { + if isNativeSearchProvider(&plainProvider{}) { + t.Fatal("expected false for provider that does not implement NativeSearchCapable") + } +} + +func TestFilterClientWebSearch_RemovesWebSearch(t *testing.T) { + defs := []providers.ToolDefinition{ + {Type: "function", Function: providers.ToolFunctionDefinition{Name: "web_search"}}, + {Type: "function", Function: providers.ToolFunctionDefinition{Name: "read_file"}}, + {Type: "function", Function: providers.ToolFunctionDefinition{Name: "exec"}}, + } + result := filterClientWebSearch(defs) + if len(result) != 2 { + t.Fatalf("len(result) = %d, want 2", len(result)) + } + for _, td := range result { + if td.Function.Name == "web_search" { + t.Fatal("web_search should be filtered out") + } + } +} + +func TestFilterClientWebSearch_NoWebSearch(t *testing.T) { + defs := []providers.ToolDefinition{ + {Type: "function", Function: providers.ToolFunctionDefinition{Name: "read_file"}}, + {Type: "function", Function: providers.ToolFunctionDefinition{Name: "exec"}}, + } + result := filterClientWebSearch(defs) + if len(result) != 2 { + t.Fatalf("len(result) = %d, want 2", len(result)) + } +} + +func TestFilterClientWebSearch_EmptyInput(t *testing.T) { + result := filterClientWebSearch(nil) + if len(result) != 0 { + t.Fatalf("len(result) = %d, want 0", len(result)) + } +} diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index c5bdbf3c3..e79d07e82 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -384,6 +384,45 @@ func TestDefaultConfig_OpenAIWebSearchEnabled(t *testing.T) { } } +func TestDefaultConfig_WebPreferNativeEnabled(t *testing.T) { + cfg := DefaultConfig() + if !cfg.Tools.Web.PreferNative { + t.Fatal("DefaultConfig().Tools.Web.PreferNative should be true") + } +} + +func TestLoadConfig_WebPreferNativeDefaultsTrueWhenUnset(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"tools":{"web":{"enabled":true}}}`), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if !cfg.Tools.Web.PreferNative { + t.Fatal("PreferNative should remain true when unset in config file") + } +} + +func TestLoadConfig_WebPreferNativeCanBeDisabled(t *testing.T) { + dir := t.TempDir() + configPath := filepath.Join(dir, "config.json") + if err := os.WriteFile(configPath, []byte(`{"tools":{"web":{"prefer_native":false}}}`), 0o600); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + + cfg, err := LoadConfig(configPath) + if err != nil { + t.Fatalf("LoadConfig() error: %v", err) + } + if cfg.Tools.Web.PreferNative { + t.Fatal("PreferNative should be false when disabled in config file") + } +} + func TestDefaultConfig_ExecAllowRemoteEnabled(t *testing.T) { cfg := DefaultConfig() if !cfg.Tools.Exec.AllowRemote { diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index ed9747f9d..a3288a023 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -824,6 +824,232 @@ func TestSupportsPromptCacheKey(t *testing.T) { } } +func TestBuildToolsList_NativeSearchAddsWebSearchPreview(t *testing.T) { + tools := []ToolDefinition{ + {Type: "function", Function: ToolFunctionDefinition{Name: "read_file", Description: "read"}}, + } + result := buildToolsList(tools, true) + if len(result) != 2 { + t.Fatalf("len(result) = %d, want 2", len(result)) + } + wsEntry, ok := result[1].(map[string]any) + if !ok { + t.Fatalf("web search entry is %T, want map[string]any", result[1]) + } + if wsEntry["type"] != "web_search_preview" { + t.Fatalf("type = %v, want web_search_preview", wsEntry["type"]) + } +} + +func TestBuildToolsList_NativeSearchFiltersClientWebSearch(t *testing.T) { + tools := []ToolDefinition{ + {Type: "function", Function: ToolFunctionDefinition{Name: "web_search", Description: "search"}}, + {Type: "function", Function: ToolFunctionDefinition{Name: "read_file", Description: "read"}}, + } + result := buildToolsList(tools, true) + for _, entry := range result { + if td, ok := entry.(ToolDefinition); ok && strings.EqualFold(td.Function.Name, "web_search") { + t.Fatal("client-side web_search should be filtered out when native search is enabled") + } + } + if len(result) != 2 { // read_file + web_search_preview + t.Fatalf("len(result) = %d, want 2 (read_file + web_search_preview)", len(result)) + } +} + +func TestBuildToolsList_NoNativeSearchPassesThrough(t *testing.T) { + tools := []ToolDefinition{ + {Type: "function", Function: ToolFunctionDefinition{Name: "web_search", Description: "search"}}, + {Type: "function", Function: ToolFunctionDefinition{Name: "read_file", Description: "read"}}, + } + result := buildToolsList(tools, false) + if len(result) != 2 { + t.Fatalf("len(result) = %d, want 2", len(result)) + } +} + +func TestIsNativeSearchHost(t *testing.T) { + tests := []struct { + apiBase string + want bool + }{ + {"https://api.openai.com/v1", true}, + {"https://myresource.openai.azure.com/openai/deployments/gpt-4", true}, + {"https://api.mistral.ai/v1", false}, + {"https://api.deepseek.com/v1", false}, + {"https://api.groq.com/openai/v1", false}, + {"http://localhost:11434/v1", false}, + {"", false}, + } + for _, tt := range tests { + if got := isNativeSearchHost(tt.apiBase); got != tt.want { + t.Errorf("isNativeSearchHost(%q) = %v, want %v", tt.apiBase, got, tt.want) + } + } +} + +func TestSupportsNativeSearch_OpenAI(t *testing.T) { + p := NewProvider("key", "https://api.openai.com/v1", "") + if !p.SupportsNativeSearch() { + t.Fatal("OpenAI provider should support native search") + } +} + +func TestSupportsNativeSearch_NonOpenAI(t *testing.T) { + p := NewProvider("key", "https://api.deepseek.com/v1", "") + if p.SupportsNativeSearch() { + t.Fatal("DeepSeek provider should not support native search") + } +} + +func TestProviderChat_NativeSearchToolInjected(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + p.apiBase = "https://api.openai.com/v1" + p.httpClient = &http.Client{ + Transport: roundTripperFunc(func(r *http.Request) (*http.Response, error) { + r.URL, _ = url.Parse(server.URL + r.URL.Path) + return http.DefaultTransport.RoundTrip(r) + }), + } + tools := []ToolDefinition{ + {Type: "function", Function: ToolFunctionDefinition{Name: "read_file", Description: "read"}}, + } + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + tools, + "gpt-5.4", + map[string]any{"native_search": true}, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + toolsRaw, ok := requestBody["tools"].([]any) + if !ok { + t.Fatalf("tools is %T, want []any", requestBody["tools"]) + } + if len(toolsRaw) != 2 { + t.Fatalf("len(tools) = %d, want 2 (read_file + web_search_preview)", len(toolsRaw)) + } + + lastTool, ok := toolsRaw[1].(map[string]any) + if !ok { + t.Fatalf("last tool is %T, want map[string]any", toolsRaw[1]) + } + if lastTool["type"] != "web_search_preview" { + t.Fatalf("last tool type = %v, want web_search_preview", lastTool["type"]) + } +} + +func TestProviderChat_NativeSearchNotInjectedWithoutOption(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + p := NewProvider("key", server.URL, "") + tools := []ToolDefinition{ + {Type: "function", Function: ToolFunctionDefinition{Name: "web_search", Description: "search"}}, + } + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + tools, + "gpt-5.4", + map[string]any{}, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + toolsRaw, ok := requestBody["tools"].([]any) + if !ok { + t.Fatalf("tools is %T, want []any", requestBody["tools"]) + } + if len(toolsRaw) != 1 { + t.Fatalf("len(tools) = %d, want 1 (web_search only)", len(toolsRaw)) + } +} + +// TestProviderChat_NativeSearchIgnoredOnNonOpenAI verifies that when native_search +// is true in options but the provider's apiBase is not OpenAI (e.g. fallback to DeepSeek), +// we do not inject web_search_preview to avoid API errors. +func TestProviderChat_NativeSearchIgnoredOnNonOpenAI(t *testing.T) { + var requestBody map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&requestBody); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + resp := map[string]any{ + "choices": []map[string]any{ + { + "message": map[string]any{"content": "ok"}, + "finish_reason": "stop", + }, + }, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(resp) + })) + defer server.Close() + + // Use server.URL so host is not api.openai.com — simulates DeepSeek/other provider + p := NewProvider("key", server.URL, "") + _, err := p.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + "deepseek-chat", + map[string]any{"native_search": true}, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + // Should not have tools at all (no tools passed, and we must not add web_search_preview) + if toolsRaw, ok := requestBody["tools"]; ok { + t.Fatalf("tools should be omitted for non-OpenAI when only native_search was requested, got %v", toolsRaw) + } +} + func TestSerializeMessages_StripsSystemParts(t *testing.T) { messages := []protocoltypes.Message{ {