diff --git a/src/resources/extensions/search-the-web/native-search.ts b/src/resources/extensions/search-the-web/native-search.ts index 3b6cd23d6..6c237fd37 100644 --- a/src/resources/extensions/search-the-web/native-search.ts +++ b/src/resources/extensions/search-the-web/native-search.ts @@ -111,10 +111,22 @@ export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic: if (!Array.isArray(payload.tools)) payload.tools = []; + let tools = payload.tools as Array>; + // Don't double-inject if already present - const tools = payload.tools as Array>; if (tools.some((t) => t.type === "web_search_20250305")) return; + // When no Brave key, remove Brave-based search tool definitions from the + // payload so Claude doesn't see (and try to call) broken tools. + // This is more reliable than setActiveTools since model_select may not fire. + const hasBrave = !!process.env.BRAVE_API_KEY; + if (!hasBrave) { + tools = tools.filter( + (t) => !BRAVE_TOOL_NAMES.includes(t.name as string) + ); + payload.tools = tools; + } + tools.push({ type: "web_search_20250305", name: "web_search", diff --git a/src/tests/native-search.test.ts b/src/tests/native-search.test.ts index b84db2aea..e466720e0 100644 --- a/src/tests/native-search.test.ts +++ b/src/tests/native-search.test.ts @@ -331,6 +331,76 @@ test("BRAVE_TOOL_NAMES contains expected tool names", () => { assert.deepEqual(BRAVE_TOOL_NAMES, ["search-the-web", "search_and_read"]); }); +test("before_provider_request removes Brave tools from payload when no BRAVE_API_KEY", async () => { + const originalKey = process.env.BRAVE_API_KEY; + delete process.env.BRAVE_API_KEY; + + try { + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + const payload: Record = { + model: "claude-sonnet-4-6-20250514", + tools: [ + { name: "bash", type: "function" }, + { name: "search-the-web", type: "function" }, + { name: "search_and_read", type: "function" }, + { name: "fetch_page", type: "function" }, + ], + }; + + const result = await pi.fire("before_provider_request", { + type: "before_provider_request", + payload, + }); + + const tools = ((result as any)?.tools ?? payload.tools) as any[]; + const names = tools.map((t: any) => t.name); + + assert.ok(!names.includes("search-the-web"), "search-the-web should be removed from payload"); + assert.ok(!names.includes("search_and_read"), "search_and_read should be removed from payload"); + assert.ok(names.includes("bash"), "bash should remain"); + assert.ok(names.includes("fetch_page"), "fetch_page should remain"); + assert.ok(names.includes("web_search"), "native web_search should be injected"); + } finally { + if (originalKey) process.env.BRAVE_API_KEY = originalKey; + else delete process.env.BRAVE_API_KEY; + } +}); + +test("before_provider_request keeps Brave tools in payload when BRAVE_API_KEY set", async () => { + const originalKey = process.env.BRAVE_API_KEY; + process.env.BRAVE_API_KEY = "test-key"; + + try { + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + const payload: Record = { + model: "claude-sonnet-4-6-20250514", + tools: [ + { name: "search-the-web", type: "function" }, + { name: "search_and_read", type: "function" }, + ], + }; + + const result = await pi.fire("before_provider_request", { + type: "before_provider_request", + payload, + }); + + const tools = ((result as any)?.tools ?? payload.tools) as any[]; + const names = tools.map((t: any) => t.name); + + assert.ok(names.includes("search-the-web"), "search-the-web should remain with Brave key"); + assert.ok(names.includes("search_and_read"), "search_and_read should remain with Brave key"); + assert.ok(names.includes("web_search"), "native web_search should also be injected"); + } finally { + if (originalKey) process.env.BRAVE_API_KEY = originalKey; + else delete process.env.BRAVE_API_KEY; + } +}); + // ─── stripThinkingFromHistory tests ───────────────────────────────────────── test("stripThinkingFromHistory removes thinking from earlier assistant messages", () => {