|
| 1 | +/* eslint-disable max-lines */ |
1 | 2 | import { |
2 | 3 | describe, |
3 | 4 | test, |
@@ -30,11 +31,12 @@ import { |
30 | 31 | webSearchInterceptor, |
31 | 32 | prepareWebSearchPayload, |
32 | 33 | } from "~/services/web-search/interceptor" |
| 34 | +import * as tavilyModule from "~/services/web-search/tavily" |
33 | 35 | import { |
34 | 36 | WEB_SEARCH_TOOL_NAMES, |
35 | 37 | WEB_SEARCH_FUNCTION_TOOL, |
36 | 38 | } from "~/services/web-search/tool-definition" |
37 | | -import { BraveSearchError } from "~/services/web-search/types" |
| 39 | +import { BraveSearchError, WebSearchError } from "~/services/web-search/types" |
38 | 40 |
|
39 | 41 | describe("WEB_SEARCH_TOOL_NAMES", () => { |
40 | 42 | test("contains web_search", () => { |
@@ -800,3 +802,264 @@ describe("isWebSearchEnabled", () => { |
800 | 802 | } |
801 | 803 | }) |
802 | 804 | }) |
| 805 | + |
| 806 | +describe("searchTavily — result formatting", () => { |
| 807 | + afterEach(() => { |
| 808 | + mock.restore() |
| 809 | + }) |
| 810 | + |
| 811 | + test("formats results mapping content to description", async () => { |
| 812 | + const mockResponse = { |
| 813 | + results: [ |
| 814 | + { title: "T1", url: "https://t.com/1", content: "C1" }, |
| 815 | + { title: "T2", url: "https://t.com/2", content: "C2" }, |
| 816 | + ], |
| 817 | + } |
| 818 | + spyOn(globalThis, "fetch").mockResolvedValue( |
| 819 | + new Response(JSON.stringify(mockResponse), { status: 200 }), |
| 820 | + ) |
| 821 | + |
| 822 | + const results = await tavilyModule.searchTavily("test query", "fake-key") |
| 823 | + expect(results).toHaveLength(2) |
| 824 | + expect(results[0]).toEqual({ |
| 825 | + title: "T1", |
| 826 | + url: "https://t.com/1", |
| 827 | + description: "C1", |
| 828 | + }) |
| 829 | + expect(results[1]?.url).toBe("https://t.com/2") |
| 830 | + }) |
| 831 | + |
| 832 | + test("returns empty array when results is empty", async () => { |
| 833 | + const mockResponse = { results: [] } |
| 834 | + spyOn(globalThis, "fetch").mockResolvedValue( |
| 835 | + new Response(JSON.stringify(mockResponse), { status: 200 }), |
| 836 | + ) |
| 837 | + |
| 838 | + const results = await tavilyModule.searchTavily("nothing here", "fake-key") |
| 839 | + expect(results).toHaveLength(0) |
| 840 | + }) |
| 841 | + |
| 842 | + test("returns empty array when results key is absent", async () => { |
| 843 | + const mockResponse = {} |
| 844 | + spyOn(globalThis, "fetch").mockResolvedValue( |
| 845 | + new Response(JSON.stringify(mockResponse), { status: 200 }), |
| 846 | + ) |
| 847 | + |
| 848 | + const results = await tavilyModule.searchTavily("nothing", "fake-key") |
| 849 | + expect(results).toHaveLength(0) |
| 850 | + }) |
| 851 | + |
| 852 | + test("sends Authorization: Bearer header", async () => { |
| 853 | + const fetchSpy = spyOn(globalThis, "fetch").mockResolvedValue( |
| 854 | + new Response(JSON.stringify({ results: [] }), { status: 200 }), |
| 855 | + ) |
| 856 | + |
| 857 | + await tavilyModule.searchTavily("q", "my-secret-key") |
| 858 | + |
| 859 | + expect(fetchSpy).toHaveBeenCalled() |
| 860 | + // Take the most recent call — it's the one our searchTavily just made |
| 861 | + const lastCall = fetchSpy.mock.calls.at(-1) |
| 862 | + expect(lastCall).toBeDefined() |
| 863 | + const headers = lastCall?.[1]?.headers as Record<string, string> |
| 864 | + expect(headers["Authorization"]).toBe("Bearer my-secret-key") |
| 865 | + }) |
| 866 | +}) |
| 867 | + |
| 868 | +describe("searchTavily — error handling", () => { |
| 869 | + afterEach(() => { |
| 870 | + mock.restore() |
| 871 | + }) |
| 872 | + |
| 873 | + test("throws WebSearchError on non-200 response", async () => { |
| 874 | + spyOn(globalThis, "fetch").mockResolvedValue( |
| 875 | + new Response("Unauthorized", { status: 401 }), |
| 876 | + ) |
| 877 | + |
| 878 | + let threw: unknown |
| 879 | + try { |
| 880 | + await tavilyModule.searchTavily("q", "bad-key") |
| 881 | + } catch (e) { |
| 882 | + threw = e |
| 883 | + } |
| 884 | + expect(threw).toBeInstanceOf(WebSearchError) |
| 885 | + }) |
| 886 | + |
| 887 | + test("WebSearchError reason includes status code on non-200", async () => { |
| 888 | + spyOn(globalThis, "fetch").mockResolvedValue( |
| 889 | + new Response("Unauthorized", { status: 401 }), |
| 890 | + ) |
| 891 | + |
| 892 | + let threw: unknown |
| 893 | + try { |
| 894 | + await tavilyModule.searchTavily("q", "bad-key") |
| 895 | + } catch (e) { |
| 896 | + threw = e |
| 897 | + } |
| 898 | + expect(threw).toBeInstanceOf(WebSearchError) |
| 899 | + expect((threw as WebSearchError).reason).toContain("401") |
| 900 | + }) |
| 901 | + |
| 902 | + test("throws WebSearchError on network failure", async () => { |
| 903 | + spyOn(globalThis, "fetch").mockRejectedValue(new Error("network error")) |
| 904 | + |
| 905 | + let threw: unknown |
| 906 | + try { |
| 907 | + await tavilyModule.searchTavily("q", "key") |
| 908 | + } catch (e) { |
| 909 | + threw = e |
| 910 | + } |
| 911 | + expect(threw).toBeInstanceOf(WebSearchError) |
| 912 | + }) |
| 913 | + |
| 914 | + test("throws WebSearchError with 'request timed out' reason on AbortError", async () => { |
| 915 | + spyOn(globalThis, "fetch").mockRejectedValue( |
| 916 | + Object.assign(new Error("aborted"), { name: "AbortError" }), |
| 917 | + ) |
| 918 | + |
| 919 | + let threw: unknown |
| 920 | + try { |
| 921 | + await tavilyModule.searchTavily("q", "key") |
| 922 | + } catch (e) { |
| 923 | + threw = e |
| 924 | + } |
| 925 | + expect(threw).toBeInstanceOf(WebSearchError) |
| 926 | + expect((threw as WebSearchError).reason).toBe("request timed out") |
| 927 | + }) |
| 928 | +}) |
| 929 | + |
| 930 | +describe("webSearchInterceptor — Tavily search path", () => { |
| 931 | + beforeEach(() => { |
| 932 | + state.tavilyApiKey = "tavily-test-key" |
| 933 | + state.braveApiKey = undefined |
| 934 | + }) |
| 935 | + |
| 936 | + afterEach(() => { |
| 937 | + state.tavilyApiKey = undefined |
| 938 | + mock.restore() |
| 939 | + }) |
| 940 | + |
| 941 | + test("calls Tavily and makes a second Copilot call when web_search is triggered", async () => { |
| 942 | + const firstResponse = makeCopilotResponse("tool_calls", [ |
| 943 | + { |
| 944 | + id: "tc-ws", |
| 945 | + name: "web_search", |
| 946 | + arguments: '{"query":"latest AI news"}', |
| 947 | + }, |
| 948 | + ]) |
| 949 | + const finalResponse = makeCopilotResponse("stop") |
| 950 | + const tavilyResults = [ |
| 951 | + { |
| 952 | + title: "AI News", |
| 953 | + url: "https://ainews.com", |
| 954 | + description: "Latest AI developments", |
| 955 | + }, |
| 956 | + ] |
| 957 | + |
| 958 | + const createSpy = spyOn( |
| 959 | + createChatCompletionsModule, |
| 960 | + "createChatCompletions", |
| 961 | + ) |
| 962 | + .mockResolvedValueOnce(firstResponse) |
| 963 | + .mockResolvedValueOnce(finalResponse) |
| 964 | + spyOn(tavilyModule, "searchTavily").mockResolvedValue(tavilyResults) |
| 965 | + |
| 966 | + const result = await webSearchInterceptor(makePayload()) |
| 967 | + |
| 968 | + expect(createSpy).toHaveBeenCalledTimes(2) |
| 969 | + expect(result).toEqual(finalResponse) |
| 970 | + expect(createSpy.mock.calls[0]?.[0]?.stream).toBe(false) |
| 971 | + }) |
| 972 | + |
| 973 | + test("prefers Tavily over Brave when both keys are set", async () => { |
| 974 | + state.tavilyApiKey = "tavily-key" |
| 975 | + state.braveApiKey = "brave-key" |
| 976 | + |
| 977 | + const firstResponse = makeCopilotResponse("tool_calls", [ |
| 978 | + { |
| 979 | + id: "tc-ws", |
| 980 | + name: "web_search", |
| 981 | + arguments: '{"query":"latest news"}', |
| 982 | + }, |
| 983 | + ]) |
| 984 | + const finalResponse = makeCopilotResponse("stop") |
| 985 | + |
| 986 | + spyOn(createChatCompletionsModule, "createChatCompletions") |
| 987 | + .mockResolvedValueOnce(firstResponse) |
| 988 | + .mockResolvedValueOnce(finalResponse) |
| 989 | + const tavilySpy = spyOn(tavilyModule, "searchTavily").mockResolvedValue([]) |
| 990 | + const braveSpy = spyOn(braveModule, "searchBrave").mockResolvedValue([]) |
| 991 | + |
| 992 | + await webSearchInterceptor(makePayload()) |
| 993 | + |
| 994 | + expect(tavilySpy).toHaveBeenCalled() |
| 995 | + expect(braveSpy).not.toHaveBeenCalled() |
| 996 | + |
| 997 | + state.braveApiKey = undefined |
| 998 | + }) |
| 999 | + |
| 1000 | + test("injects failure message when Tavily throws WebSearchError", async () => { |
| 1001 | + const firstResponse = makeCopilotResponse("tool_calls", [ |
| 1002 | + { id: "tc-ws", name: "web_search", arguments: '{"query":"q"}' }, |
| 1003 | + ]) |
| 1004 | + const finalResponse = makeCopilotResponse("stop") |
| 1005 | + |
| 1006 | + const createSpy = spyOn( |
| 1007 | + createChatCompletionsModule, |
| 1008 | + "createChatCompletions", |
| 1009 | + ) |
| 1010 | + .mockResolvedValueOnce(firstResponse) |
| 1011 | + .mockResolvedValueOnce(finalResponse) |
| 1012 | + spyOn(tavilyModule, "searchTavily").mockRejectedValue( |
| 1013 | + new WebSearchError("HTTP 429"), |
| 1014 | + ) |
| 1015 | + |
| 1016 | + await webSearchInterceptor(makePayload()) |
| 1017 | + |
| 1018 | + expect(createSpy).toHaveBeenCalledTimes(2) |
| 1019 | + const secondCallMessages = createSpy.mock.calls[1]?.[0]?.messages |
| 1020 | + const toolMsg = secondCallMessages.find((m) => m.role === "tool") |
| 1021 | + expect(toolMsg?.content).toContain("Web search failed") |
| 1022 | + expect(toolMsg?.content).toContain("training data") |
| 1023 | + }) |
| 1024 | +}) |
| 1025 | + |
| 1026 | +describe("isWebSearchEnabled — Tavily", () => { |
| 1027 | + test("returns false when neither key is set", () => { |
| 1028 | + const originalBrave = stateModule.state.braveApiKey |
| 1029 | + const originalTavily = stateModule.state.tavilyApiKey |
| 1030 | + stateModule.state.braveApiKey = undefined |
| 1031 | + stateModule.state.tavilyApiKey = undefined |
| 1032 | + try { |
| 1033 | + expect(stateModule.isWebSearchEnabled()).toBe(false) |
| 1034 | + } finally { |
| 1035 | + stateModule.state.braveApiKey = originalBrave |
| 1036 | + stateModule.state.tavilyApiKey = originalTavily |
| 1037 | + } |
| 1038 | + }) |
| 1039 | + |
| 1040 | + test("returns true when tavilyApiKey is set", () => { |
| 1041 | + const originalBrave = stateModule.state.braveApiKey |
| 1042 | + const originalTavily = stateModule.state.tavilyApiKey |
| 1043 | + stateModule.state.braveApiKey = undefined |
| 1044 | + stateModule.state.tavilyApiKey = "test-key" |
| 1045 | + try { |
| 1046 | + expect(stateModule.isWebSearchEnabled()).toBe(true) |
| 1047 | + } finally { |
| 1048 | + stateModule.state.braveApiKey = originalBrave |
| 1049 | + stateModule.state.tavilyApiKey = originalTavily |
| 1050 | + } |
| 1051 | + }) |
| 1052 | + |
| 1053 | + test("returns true when both keys are set", () => { |
| 1054 | + const originalBrave = stateModule.state.braveApiKey |
| 1055 | + const originalTavily = stateModule.state.tavilyApiKey |
| 1056 | + stateModule.state.braveApiKey = "brave-key" |
| 1057 | + stateModule.state.tavilyApiKey = "tavily-key" |
| 1058 | + try { |
| 1059 | + expect(stateModule.isWebSearchEnabled()).toBe(true) |
| 1060 | + } finally { |
| 1061 | + stateModule.state.braveApiKey = originalBrave |
| 1062 | + stateModule.state.tavilyApiKey = originalTavily |
| 1063 | + } |
| 1064 | + }) |
| 1065 | +}) |
0 commit comments