From 2a89b3f56c5fdfa2cd756d3a09a450c2c1de7ba5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 15:45:54 -0300 Subject: [PATCH 1/5] feat: add native Anthropic web search via before_provider_request hook Inject the web_search_20250305 server-side tool into Anthropic API requests, eliminating the BRAVE_API_KEY requirement for Anthropic models. When Anthropic + no Brave key, custom search tools are disabled to avoid confusing the LLM with broken tools. fetch_page (Jina) is unaffected. Co-Authored-By: Claude Opus 4.6 --- .../extensions/search-the-web/index.ts | 41 +++----- .../search-the-web/native-search.ts | 98 +++++++++++++++++++ 2 files changed, 114 insertions(+), 25 deletions(-) create mode 100644 src/resources/extensions/search-the-web/native-search.ts diff --git a/src/resources/extensions/search-the-web/index.ts b/src/resources/extensions/search-the-web/index.ts index 7527904f2..a3c71b661 100644 --- a/src/resources/extensions/search-the-web/index.ts +++ b/src/resources/extensions/search-the-web/index.ts @@ -1,5 +1,5 @@ /** - * Web Search Extension v3 + * Web Search Extension v4 * * Provides three tools for grounding the agent in real-world web content: * @@ -15,6 +15,15 @@ * Returns pre-extracted, relevance-scored page content. * Best when you need content, not just links. * + * v4: Native Anthropic web search + * - When using an Anthropic provider, injects the native `web_search_20250305` + * server-side tool via `before_provider_request`. This eliminates the need for + * a BRAVE_API_KEY when using Anthropic models — search is billed through the + * existing Anthropic API key ($0.01/search). + * - Custom Brave-based tools (search-the-web, search_and_read) are disabled when + * Anthropic + no BRAVE_API_KEY to avoid confusing the LLM with broken tools. + * - fetch_page (Jina) remains available — it works without a key at lower rate limits. + * * v3 improvements over v2: * - search_and_read: New tool — Brave LLM Context API (search + read in one call) * - Structured error taxonomy: auth_error, rate_limited, network_error, etc. @@ -30,7 +39,8 @@ * - Cache timer cleanup: purge timers use unref() to not block process exit * * Environment variables: - * BRAVE_API_KEY — Required for search. Get one at brave.com/search/api + * BRAVE_API_KEY — Optional with Anthropic models (built-in search available). + * Required for non-Anthropic providers. Get one at brave.com/search/api * JINA_API_KEY — Optional. Higher rate limits for page extraction. */ @@ -39,36 +49,17 @@ import { registerSearchTool } from "./tool-search"; import { registerFetchPageTool } from "./tool-fetch-page"; import { registerLLMContextTool } from "./tool-llm-context"; import { registerSearchProviderCommand } from "./command-search-provider.ts"; +import { registerNativeSearchHooks } from "./native-search"; export default function (pi: ExtensionAPI) { - // Register all tools registerSearchTool(pi); registerFetchPageTool(pi); registerLLMContextTool(pi); + // Register slash commands registerSearchProviderCommand(pi); - // Startup diagnostics - pi.on("session_start", async (_event, ctx) => { - const hasBrave = !!process.env.BRAVE_API_KEY; - const hasTavily = !!process.env.TAVILY_API_KEY; - const hasJina = !!process.env.JINA_API_KEY; - const hasAnswers = !!process.env.BRAVE_ANSWERS_KEY; - - if (!hasBrave && !hasTavily) { - ctx.ui.notify( - "Web search: Set BRAVE_API_KEY or TAVILY_API_KEY for web search capability", - "warning" - ); - } - - const parts: string[] = ["Web search v3"]; - if (hasTavily) parts.push("Tavily ✓"); - if (hasBrave) parts.push("Search ✓"); - if (hasAnswers) parts.push("Answers ✓"); - if (hasJina) parts.push("Jina ✓"); - - ctx.ui.notify(parts.join(" · "), "info"); - }); + // Register native Anthropic web search hooks + registerNativeSearchHooks(pi); } diff --git a/src/resources/extensions/search-the-web/native-search.ts b/src/resources/extensions/search-the-web/native-search.ts new file mode 100644 index 000000000..73edba843 --- /dev/null +++ b/src/resources/extensions/search-the-web/native-search.ts @@ -0,0 +1,98 @@ +/** + * Native Anthropic web search hook logic. + * + * Extracted from index.ts so it can be unit-tested without importing + * the heavy tool-registration modules. + */ + +/** Tool names for the Brave-backed custom search tools */ +export const BRAVE_TOOL_NAMES = ["search-the-web", "search_and_read"]; + +/** Minimal interface matching the subset of ExtensionAPI we use */ +export interface NativeSearchPI { + on(event: string, handler: (...args: any[]) => any): void; + getActiveTools(): string[]; + setActiveTools(tools: string[]): void; +} + +/** + * Register model_select, before_provider_request, and session_start hooks + * for native Anthropic web search injection. + * + * Returns the isAnthropicProvider getter for testing. + */ +export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic: () => boolean } { + let isAnthropicProvider = false; + + // Track provider changes via model selection + pi.on("model_select", async (event: any, _ctx: any) => { + const wasAnthropic = isAnthropicProvider; + isAnthropicProvider = event.model.provider === "anthropic"; + + const hasBrave = !!process.env.BRAVE_API_KEY; + + // When Anthropic + no Brave key: disable custom search tools (they'd fail) + if (isAnthropicProvider && !hasBrave) { + const active = pi.getActiveTools(); + pi.setActiveTools( + active.filter((t: string) => !BRAVE_TOOL_NAMES.includes(t)) + ); + } else if (!isAnthropicProvider && wasAnthropic && !hasBrave) { + // Switching away from Anthropic without Brave — re-enable so the user + // sees the "missing key" error rather than tools silently vanishing + const active = pi.getActiveTools(); + pi.setActiveTools([...active, ...BRAVE_TOOL_NAMES]); + } + }); + + // Inject native web search into Anthropic API requests + pi.on("before_provider_request", (event: any) => { + const payload = event.payload as Record; + if (!payload) return; + + // Detect Anthropic by model name prefix (works even before model_select fires) + const model = payload.model as string | undefined; + if (!model || !model.startsWith("claude")) return; + + // Keep provider tracking in sync + isAnthropicProvider = true; + + if (!Array.isArray(payload.tools)) payload.tools = []; + + // Don't double-inject if already present + const tools = payload.tools as Array>; + if (tools.some((t) => t.type === "web_search_20250305")) return; + + tools.push({ + type: "web_search_20250305", + name: "web_search", + }); + + return payload; + }); + + // Startup diagnostics + pi.on("session_start", async (_event: any, ctx: any) => { + const hasBrave = !!process.env.BRAVE_API_KEY; + const hasJina = !!process.env.JINA_API_KEY; + const hasAnswers = !!process.env.BRAVE_ANSWERS_KEY; + + const parts: string[] = ["Web search v4 loaded"]; + + if (isAnthropicProvider) parts.push("Native search ✓"); + if (hasBrave) parts.push("Brave ✓"); + if (hasAnswers) parts.push("Answers ✓"); + if (hasJina) parts.push("Jina ✓"); + + if (!isAnthropicProvider && !hasBrave) { + ctx.ui.notify( + "Web search: Set BRAVE_API_KEY or use an Anthropic model for built-in search", + "warning" + ); + } + + ctx.ui.notify(parts.join(" · "), "info"); + }); + + return { getIsAnthropic: () => isAnthropicProvider }; +} From 4ba7930240360ded29e074ed2aca66ee7523ff5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 15:46:13 -0300 Subject: [PATCH 2/5] test: add tests for native Anthropic web search hook logic 12 tests covering: tool injection for claude models, non-claude passthrough, double-injection prevention, tool deactivation/reactivation on model switch, and session_start diagnostics. Co-Authored-By: Claude Opus 4.6 --- src/tests/native-search.test.ts | 312 ++++++++++++++++++++++++++++++++ 1 file changed, 312 insertions(+) create mode 100644 src/tests/native-search.test.ts diff --git a/src/tests/native-search.test.ts b/src/tests/native-search.test.ts new file mode 100644 index 000000000..c4c413d32 --- /dev/null +++ b/src/tests/native-search.test.ts @@ -0,0 +1,312 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { + registerNativeSearchHooks, + BRAVE_TOOL_NAMES, + type NativeSearchPI, +} from "../resources/extensions/search-the-web/native-search.ts"; + +/** + * Tests for native Anthropic web search injection. + * + * Tests the hook logic in native-search.ts directly (no heavy tool deps). + */ + +// ─── Mock ExtensionAPI ────────────────────────────────────────────────────── + +interface MockHandler { + event: string; + handler: (...args: any[]) => any; +} + +function createMockPI() { + const handlers: MockHandler[] = []; + let activeTools = ["search-the-web", "search_and_read", "fetch_page", "bash", "read"]; + const notifications: Array<{ message: string; level: string }> = []; + + const mockCtx = { + ui: { + notify(message: string, level: string) { + notifications.push({ message, level }); + }, + }, + }; + + const pi: NativeSearchPI & { + handlers: MockHandler[]; + notifications: typeof notifications; + mockCtx: typeof mockCtx; + fire(event: string, eventData: any, ctx?: any): Promise; + } = { + handlers, + notifications, + mockCtx, + on(event: string, handler: (...args: any[]) => any) { + handlers.push({ event, handler }); + }, + getActiveTools() { + return [...activeTools]; + }, + setActiveTools(tools: string[]) { + activeTools = tools; + }, + async fire(event: string, eventData: any, ctx?: any) { + for (const h of handlers) { + if (h.event === event) { + return await h.handler(eventData, ctx ?? mockCtx); + } + } + }, + }; + + return pi; +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +test("before_provider_request injects web_search for claude models", async () => { + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + const payload: Record = { + model: "claude-sonnet-4-6-20250514", + tools: [{ name: "bash", type: "custom" }], + }; + + const result = await pi.fire("before_provider_request", { + type: "before_provider_request", + payload, + }); + + const tools = (result as any)?.tools ?? payload.tools; + const hasNative = (tools as any[]).some( + (t: any) => t.type === "web_search_20250305" + ); + assert.ok(hasNative, "Should inject web_search_20250305 tool"); + assert.equal((tools as any[]).length, 2, "Should have original + injected tool"); +}); + +test("before_provider_request does NOT inject for non-claude models", async () => { + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + const payload: Record = { + model: "gpt-4o", + tools: [{ name: "bash", type: "custom" }], + }; + + const result = await pi.fire("before_provider_request", { + type: "before_provider_request", + payload, + }); + + assert.equal(result, undefined, "Should not modify non-claude payload"); + const tools = payload.tools as any[]; + assert.equal(tools.length, 1, "Should not add tools to non-claude payload"); +}); + +test("before_provider_request does not double-inject", async () => { + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + const payload: Record = { + model: "claude-opus-4-6-20250514", + tools: [{ type: "web_search_20250305", name: "web_search" }], + }; + + const result = await pi.fire("before_provider_request", { + type: "before_provider_request", + payload, + }); + + assert.equal(result, undefined, "Should not modify when already injected"); + const tools = payload.tools as any[]; + assert.equal(tools.length, 1, "Should not duplicate web_search tool"); +}); + +test("before_provider_request creates tools array if missing", async () => { + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + const payload: Record = { + model: "claude-haiku-4-5-20251001", + }; + + const result = await pi.fire("before_provider_request", { + type: "before_provider_request", + payload, + }); + + const tools = (result as any)?.tools ?? payload.tools; + assert.ok(Array.isArray(tools), "Should create tools array"); + assert.equal((tools as any[]).length, 1, "Should have exactly 1 tool"); + assert.equal((tools as any[])[0].type, "web_search_20250305"); +}); + +test("before_provider_request skips when payload is falsy", async () => { + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + const result = await pi.fire("before_provider_request", { + type: "before_provider_request", + payload: null, + }); + + assert.equal(result, undefined, "Should return undefined for null payload"); +}); + +test("model_select disables Brave tools when Anthropic + no BRAVE_API_KEY", async () => { + const originalKey = process.env.BRAVE_API_KEY; + delete process.env.BRAVE_API_KEY; + + try { + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + await pi.fire("model_select", { + type: "model_select", + model: { provider: "anthropic", name: "claude-sonnet-4-6" }, + previousModel: undefined, + source: "set", + }); + + const active = pi.getActiveTools(); + assert.ok(!active.includes("search-the-web"), "search-the-web should be disabled"); + assert.ok(!active.includes("search_and_read"), "search_and_read should be disabled"); + assert.ok(active.includes("fetch_page"), "fetch_page should remain active"); + assert.ok(active.includes("bash"), "Other tools should remain active"); + } finally { + if (originalKey) process.env.BRAVE_API_KEY = originalKey; + else delete process.env.BRAVE_API_KEY; + } +}); + +test("model_select keeps Brave tools when BRAVE_API_KEY is set", async () => { + const originalKey = process.env.BRAVE_API_KEY; + process.env.BRAVE_API_KEY = "test-key"; + + try { + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + await pi.fire("model_select", { + type: "model_select", + model: { provider: "anthropic", name: "claude-sonnet-4-6" }, + previousModel: undefined, + source: "set", + }); + + const active = pi.getActiveTools(); + assert.ok(active.includes("search-the-web"), "search-the-web should stay active"); + assert.ok(active.includes("search_and_read"), "search_and_read should stay active"); + } finally { + if (originalKey) process.env.BRAVE_API_KEY = originalKey; + else delete process.env.BRAVE_API_KEY; + } +}); + +test("model_select re-enables Brave tools when switching away from Anthropic", async () => { + const originalKey = process.env.BRAVE_API_KEY; + delete process.env.BRAVE_API_KEY; + + try { + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + // First: select Anthropic — disables Brave tools + await pi.fire("model_select", { + type: "model_select", + model: { provider: "anthropic", name: "claude-sonnet-4-6" }, + previousModel: undefined, + source: "set", + }); + + let active = pi.getActiveTools(); + assert.ok(!active.includes("search-the-web"), "Should disable after Anthropic select"); + + // Second: switch to non-Anthropic — re-enables + await pi.fire("model_select", { + type: "model_select", + model: { provider: "openai", name: "gpt-4o" }, + previousModel: { provider: "anthropic", name: "claude-sonnet-4-6" }, + source: "set", + }); + + active = pi.getActiveTools(); + assert.ok(active.includes("search-the-web"), "search-the-web should be re-enabled"); + assert.ok(active.includes("search_and_read"), "search_and_read should be re-enabled"); + } finally { + if (originalKey) process.env.BRAVE_API_KEY = originalKey; + else delete process.env.BRAVE_API_KEY; + } +}); + +test("session_start shows 'Native search' when Anthropic provider", async () => { + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + // Simulate an Anthropic request so isAnthropicProvider becomes true + await pi.fire("before_provider_request", { + type: "before_provider_request", + payload: { model: "claude-sonnet-4-6-20250514", tools: [] }, + }); + + await pi.fire("session_start", { type: "session_start" }); + + const infoNotif = pi.notifications.find( + (n) => n.level === "info" && n.message.includes("v4") + ); + assert.ok(infoNotif, "Should have v4 info notification"); + assert.ok( + infoNotif!.message.includes("Native search"), + `Should include 'Native search' — got: ${infoNotif!.message}` + ); +}); + +test("session_start shows warning when no Anthropic and no Brave key", async () => { + const originalKey = process.env.BRAVE_API_KEY; + delete process.env.BRAVE_API_KEY; + + try { + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + // Don't fire any model/request events — isAnthropicProvider stays false + await pi.fire("session_start", { type: "session_start" }); + + const warning = pi.notifications.find((n) => n.level === "warning"); + assert.ok(warning, "Should show warning when no Anthropic and no Brave key"); + assert.ok( + warning!.message.includes("Anthropic"), + `Warning should mention Anthropic — got: ${warning!.message}` + ); + } finally { + if (originalKey) process.env.BRAVE_API_KEY = originalKey; + else delete process.env.BRAVE_API_KEY; + } +}); + +test("session_start does NOT show warning when Brave key present", async () => { + const originalKey = process.env.BRAVE_API_KEY; + process.env.BRAVE_API_KEY = "test-key"; + + try { + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + await pi.fire("session_start", { type: "session_start" }); + + const warning = pi.notifications.find((n) => n.level === "warning"); + assert.equal(warning, undefined, "Should NOT show warning when Brave key is present"); + + const info = pi.notifications.find((n) => n.level === "info"); + assert.ok(info!.message.includes("Brave"), "Should mention Brave in status"); + } finally { + if (originalKey) process.env.BRAVE_API_KEY = originalKey; + else delete process.env.BRAVE_API_KEY; + } +}); + +test("BRAVE_TOOL_NAMES contains expected tool names", () => { + assert.deepEqual(BRAVE_TOOL_NAMES, ["search-the-web", "search_and_read"]); +}); From 2252a6dfcac6fb507befac711b7333d615381d13 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 16:19:57 -0300 Subject: [PATCH 3/5] fix: strip thinking blocks from history to fix conversation replay error The Pi SDK's streaming parser drops server_tool_use and web_search_tool_result content blocks. When the conversation is replayed, assistant messages are incomplete, causing the Anthropic API to reject requests with "thinking blocks cannot be modified." Fix: stripThinkingFromHistory() removes thinking/redacted_thinking blocks from all assistant messages before sending, since they're all from stored history. The model generates fresh thinking for each new turn. Co-Authored-By: Claude Opus 4.6 --- .../search-the-web/native-search.ts | 67 +++++-- src/tests/native-search.test.ts | 172 ++++++++++++++++-- 2 files changed, 208 insertions(+), 31 deletions(-) diff --git a/src/resources/extensions/search-the-web/native-search.ts b/src/resources/extensions/search-the-web/native-search.ts index 73edba843..3b6cd23d6 100644 --- a/src/resources/extensions/search-the-web/native-search.ts +++ b/src/resources/extensions/search-the-web/native-search.ts @@ -8,6 +8,9 @@ /** Tool names for the Brave-backed custom search tools */ export const BRAVE_TOOL_NAMES = ["search-the-web", "search_and_read"]; +/** Thinking block types that require signature validation by the API */ +const THINKING_TYPES = new Set(["thinking", "redacted_thinking"]); + /** Minimal interface matching the subset of ExtensionAPI we use */ export interface NativeSearchPI { on(event: string, handler: (...args: any[]) => any): void; @@ -15,6 +18,37 @@ export interface NativeSearchPI { setActiveTools(tools: string[]): void; } +/** + * Strip thinking/redacted_thinking blocks from assistant messages in the + * conversation history. + * + * Why: The Pi SDK's streaming parser drops `server_tool_use` and + * `web_search_tool_result` content blocks (unknown types). When the + * conversation is replayed, the assistant messages are incomplete — missing + * those blocks. The Anthropic API detects the modification and rejects the + * request with "thinking blocks cannot be modified." + * + * Fix: Remove thinking blocks from all assistant messages in the history. + * In Anthropic's Messages API, the messages array always ends with a user + * message, so every assistant message is from a previous turn that has been + * through a store/replay cycle. The model generates fresh thinking for the + * current turn regardless. + */ +export function stripThinkingFromHistory( + messages: Array> +): void { + for (const msg of messages) { + if (msg.role !== "assistant") continue; + + const content = msg.content; + if (!Array.isArray(content)) continue; + + msg.content = content.filter( + (block: any) => !THINKING_TYPES.has(block?.type) + ); + } +} + /** * Register model_select, before_provider_request, and session_start hooks * for native Anthropic web search injection. @@ -24,8 +58,9 @@ export interface NativeSearchPI { export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic: () => boolean } { let isAnthropicProvider = false; - // Track provider changes via model selection - pi.on("model_select", async (event: any, _ctx: any) => { + // Track provider changes via model selection — also handles diagnostics + // since model_select fires AFTER session_start and knows the provider. + pi.on("model_select", async (event: any, ctx: any) => { const wasAnthropic = isAnthropicProvider; isAnthropicProvider = event.model.provider === "anthropic"; @@ -43,6 +78,16 @@ export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic: const active = pi.getActiveTools(); pi.setActiveTools([...active, ...BRAVE_TOOL_NAMES]); } + + // Show provider-aware diagnostics on first selection or provider change + if (isAnthropicProvider && !wasAnthropic) { + ctx.ui.notify("Native Anthropic web search active", "info"); + } else if (!isAnthropicProvider && !hasBrave) { + ctx.ui.notify( + "Web search: Set BRAVE_API_KEY or use an Anthropic model for built-in search", + "warning" + ); + } }); // Inject native web search into Anthropic API requests @@ -57,6 +102,13 @@ export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic: // Keep provider tracking in sync isAnthropicProvider = true; + // Strip thinking blocks from history to avoid signature validation errors + // caused by the SDK dropping server_tool_use/web_search_tool_result blocks. + const messages = payload.messages as Array> | undefined; + if (Array.isArray(messages)) { + stripThinkingFromHistory(messages); + } + if (!Array.isArray(payload.tools)) payload.tools = []; // Don't double-inject if already present @@ -71,26 +123,17 @@ export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic: return payload; }); - // Startup diagnostics + // Basic startup diagnostics — provider-specific info comes from model_select pi.on("session_start", async (_event: any, ctx: any) => { const hasBrave = !!process.env.BRAVE_API_KEY; const hasJina = !!process.env.JINA_API_KEY; const hasAnswers = !!process.env.BRAVE_ANSWERS_KEY; const parts: string[] = ["Web search v4 loaded"]; - - if (isAnthropicProvider) parts.push("Native search ✓"); if (hasBrave) parts.push("Brave ✓"); if (hasAnswers) parts.push("Answers ✓"); if (hasJina) parts.push("Jina ✓"); - if (!isAnthropicProvider && !hasBrave) { - ctx.ui.notify( - "Web search: Set BRAVE_API_KEY or use an Anthropic model for built-in search", - "warning" - ); - } - ctx.ui.notify(parts.join(" · "), "info"); }); diff --git a/src/tests/native-search.test.ts b/src/tests/native-search.test.ts index c4c413d32..b84db2aea 100644 --- a/src/tests/native-search.test.ts +++ b/src/tests/native-search.test.ts @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { registerNativeSearchHooks, + stripThinkingFromHistory, BRAVE_TOOL_NAMES, type NativeSearchPI, } from "../resources/extensions/search-the-web/native-search.ts"; @@ -241,29 +242,28 @@ test("model_select re-enables Brave tools when switching away from Anthropic", a } }); -test("session_start shows 'Native search' when Anthropic provider", async () => { +test("model_select shows 'Native Anthropic web search active' for Anthropic provider", async () => { const pi = createMockPI(); registerNativeSearchHooks(pi); - // Simulate an Anthropic request so isAnthropicProvider becomes true - await pi.fire("before_provider_request", { - type: "before_provider_request", - payload: { model: "claude-sonnet-4-6-20250514", tools: [] }, + await pi.fire("model_select", { + type: "model_select", + model: { provider: "anthropic", name: "claude-sonnet-4-6" }, + previousModel: undefined, + source: "set", }); - await pi.fire("session_start", { type: "session_start" }); - const infoNotif = pi.notifications.find( - (n) => n.level === "info" && n.message.includes("v4") + (n) => n.level === "info" && n.message.includes("Native") ); - assert.ok(infoNotif, "Should have v4 info notification"); + assert.ok(infoNotif, "Should notify about native search on Anthropic model_select"); assert.ok( - infoNotif!.message.includes("Native search"), - `Should include 'Native search' — got: ${infoNotif!.message}` + infoNotif!.message.includes("Native Anthropic web search active"), + `Should say 'Native Anthropic web search active' — got: ${infoNotif!.message}` ); }); -test("session_start shows warning when no Anthropic and no Brave key", async () => { +test("model_select shows warning for non-Anthropic without Brave key", async () => { const originalKey = process.env.BRAVE_API_KEY; delete process.env.BRAVE_API_KEY; @@ -271,11 +271,15 @@ test("session_start shows warning when no Anthropic and no Brave key", async () const pi = createMockPI(); registerNativeSearchHooks(pi); - // Don't fire any model/request events — isAnthropicProvider stays false - await pi.fire("session_start", { type: "session_start" }); + await pi.fire("model_select", { + type: "model_select", + model: { provider: "openai", name: "gpt-4o" }, + previousModel: undefined, + source: "set", + }); const warning = pi.notifications.find((n) => n.level === "warning"); - assert.ok(warning, "Should show warning when no Anthropic and no Brave key"); + assert.ok(warning, "Should show warning for non-Anthropic without Brave key"); assert.ok( warning!.message.includes("Anthropic"), `Warning should mention Anthropic — got: ${warning!.message}` @@ -286,7 +290,23 @@ test("session_start shows warning when no Anthropic and no Brave key", async () } }); -test("session_start does NOT show warning when Brave key present", async () => { +test("session_start shows v4 loaded message", async () => { + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + await pi.fire("session_start", { type: "session_start" }); + + const infoNotif = pi.notifications.find( + (n) => n.level === "info" && n.message.includes("v4") + ); + assert.ok(infoNotif, "Should have v4 info notification"); + assert.ok( + infoNotif!.message.startsWith("Web search v4 loaded"), + `Should start with 'Web search v4 loaded' — got: ${infoNotif!.message}` + ); +}); + +test("session_start shows Brave status when key present", async () => { const originalKey = process.env.BRAVE_API_KEY; process.env.BRAVE_API_KEY = "test-key"; @@ -296,11 +316,11 @@ test("session_start does NOT show warning when Brave key present", async () => { await pi.fire("session_start", { type: "session_start" }); - const warning = pi.notifications.find((n) => n.level === "warning"); - assert.equal(warning, undefined, "Should NOT show warning when Brave key is present"); - const info = pi.notifications.find((n) => n.level === "info"); assert.ok(info!.message.includes("Brave"), "Should mention Brave in status"); + + const warning = pi.notifications.find((n) => n.level === "warning"); + assert.equal(warning, undefined, "Should NOT show warning when Brave key is present"); } finally { if (originalKey) process.env.BRAVE_API_KEY = originalKey; else delete process.env.BRAVE_API_KEY; @@ -310,3 +330,117 @@ test("session_start does NOT show warning when Brave key present", async () => { test("BRAVE_TOOL_NAMES contains expected tool names", () => { assert.deepEqual(BRAVE_TOOL_NAMES, ["search-the-web", "search_and_read"]); }); + +// ─── stripThinkingFromHistory tests ───────────────────────────────────────── + +test("stripThinkingFromHistory removes thinking from earlier assistant messages", () => { + const messages: any[] = [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "hmm", signature: "sig1" }, + { type: "text", text: "Hi there" }, + ], + }, + { role: "user", content: "search something" }, + ]; + + stripThinkingFromHistory(messages); + + // First assistant message (not latest) — thinking stripped + assert.equal(messages[1].content.length, 1); + assert.equal(messages[1].content[0].type, "text"); +}); + +test("stripThinkingFromHistory strips thinking from all assistant messages", () => { + const messages: any[] = [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "first thought", signature: "sig1" }, + { type: "text", text: "response 1" }, + ], + }, + { role: "user", content: "follow up" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "second thought", signature: "sig2" }, + { type: "text", text: "response 2" }, + ], + }, + { role: "user", content: "another question" }, + ]; + + stripThinkingFromHistory(messages); + + // Both assistant messages — thinking stripped + assert.equal(messages[1].content.length, 1); + assert.equal(messages[1].content[0].type, "text"); + + assert.equal(messages[3].content.length, 1); + assert.equal(messages[3].content[0].type, "text"); +}); + +test("stripThinkingFromHistory removes redacted_thinking too", () => { + const messages: any[] = [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: [ + { type: "redacted_thinking", data: "opaque" }, + { type: "text", text: "response" }, + ], + }, + { role: "user", content: "next" }, + ]; + + stripThinkingFromHistory(messages); + + assert.equal(messages[1].content.length, 1); + assert.equal(messages[1].content[0].type, "text"); +}); + +test("stripThinkingFromHistory strips even single assistant message", () => { + const messages: any[] = [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "thought", signature: "sig" }, + { type: "text", text: "response" }, + ], + }, + { role: "user", content: "follow up" }, + ]; + + stripThinkingFromHistory(messages); + + // Thinking stripped — all assistant messages are from stored history + assert.equal(messages[1].content.length, 1); + assert.equal(messages[1].content[0].type, "text"); +}); + +test("stripThinkingFromHistory handles no assistant messages", () => { + const messages: any[] = [ + { role: "user", content: "hello" }, + ]; + + // Should not throw + stripThinkingFromHistory(messages); + assert.equal(messages.length, 1); +}); + +test("stripThinkingFromHistory handles string content (no array)", () => { + const messages: any[] = [ + { role: "user", content: "hello" }, + { role: "assistant", content: "just a string" }, + { role: "user", content: "next" }, + ]; + + // Should not throw — string content is skipped + stripThinkingFromHistory(messages); + assert.equal(messages[1].content, "just a string"); +}); From e22a2f762253b70e938e51e068c3ea3e8a59db77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 17:54:09 -0300 Subject: [PATCH 4/5] fix: remove Brave search tools from API payload when no BRAVE_API_KEY The model_select event doesn't reliably fire on startup, so Brave tools remained visible to Claude even without a key. Now before_provider_request filters search-the-web and search_and_read from the payload directly, ensuring Claude only sees the native web_search tool. Co-Authored-By: Claude Opus 4.6 --- .../search-the-web/native-search.ts | 14 +++- src/tests/native-search.test.ts | 70 +++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) 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", () => { From a595b9e28e4706caaa60873721974d20dcace1c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Facu=5FVi=C3=B1as?= Date: Wed, 11 Mar 2026 22:06:05 -0300 Subject: [PATCH 5/5] fix: prevent duplicate tools on provider toggle, suppress restore notifications, fix Windows test globs - Prevent duplicate Brave tool entries when toggling providers repeatedly by filtering already-active tools before re-adding (BUG-1) - Remove single quotes from test glob patterns in package.json so Windows shell expands them correctly (BUG-2) - Fix test mock fire() to call all handlers instead of short-circuiting on first match, matching real framework behavior (BUG-3) - Suppress "Native Anthropic web search active" notification on session restore (source: "restore") to reduce UX noise (BUG-4) - Add regression tests for all 4 bugs Co-Authored-By: Claude Opus 4.6 --- package.json | 2 +- .../search-the-web/native-search.ts | 10 +- src/tests/native-search.test.ts | 122 +++++++++++++++++- 3 files changed, 129 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index e6816fe97..dbf129ebb 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "scripts": { "build": "tsc && npm run copy-themes", "copy-themes": "node -e \"const{mkdirSync,cpSync}=require('fs');const{resolve}=require('path');const src=resolve(__dirname,'node_modules/@mariozechner/pi-coding-agent/dist/modes/interactive/theme');mkdirSync('pkg/dist/modes/interactive/theme',{recursive:true});cpSync(src,'pkg/dist/modes/interactive/theme',{recursive:true})\"", - "test": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test 'src/resources/extensions/gsd/tests/*.test.ts' 'src/resources/extensions/gsd/tests/*.test.mjs' 'src/tests/*.test.ts'", + "test": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/*.test.ts src/resources/extensions/gsd/tests/*.test.mjs src/tests/*.test.ts", "dev": "tsc --watch", "postinstall": "node scripts/postinstall.js", "pi:install-global": "node scripts/install-pi-global.js", diff --git a/src/resources/extensions/search-the-web/native-search.ts b/src/resources/extensions/search-the-web/native-search.ts index 6c237fd37..30fda9e46 100644 --- a/src/resources/extensions/search-the-web/native-search.ts +++ b/src/resources/extensions/search-the-web/native-search.ts @@ -74,13 +74,17 @@ export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic: ); } else if (!isAnthropicProvider && wasAnthropic && !hasBrave) { // Switching away from Anthropic without Brave — re-enable so the user - // sees the "missing key" error rather than tools silently vanishing + // sees the "missing key" error rather than tools silently vanishing. + // Only add tools not already active to avoid duplicates on repeated toggles. const active = pi.getActiveTools(); - pi.setActiveTools([...active, ...BRAVE_TOOL_NAMES]); + const toAdd = BRAVE_TOOL_NAMES.filter((t) => !active.includes(t)); + if (toAdd.length > 0) { + pi.setActiveTools([...active, ...toAdd]); + } } // Show provider-aware diagnostics on first selection or provider change - if (isAnthropicProvider && !wasAnthropic) { + if (isAnthropicProvider && !wasAnthropic && event.source !== "restore") { ctx.ui.notify("Native Anthropic web search active", "info"); } else if (!isAnthropicProvider && !hasBrave) { ctx.ui.notify( diff --git a/src/tests/native-search.test.ts b/src/tests/native-search.test.ts index e466720e0..02f566764 100644 --- a/src/tests/native-search.test.ts +++ b/src/tests/native-search.test.ts @@ -52,11 +52,14 @@ function createMockPI() { activeTools = tools; }, async fire(event: string, eventData: any, ctx?: any) { + let lastResult: any; for (const h of handlers) { if (h.event === event) { - return await h.handler(eventData, ctx ?? mockCtx); + const result = await h.handler(eventData, ctx ?? mockCtx); + if (result !== undefined) lastResult = result; } } + return lastResult; }, }; @@ -401,6 +404,123 @@ test("before_provider_request keeps Brave tools in payload when BRAVE_API_KEY se } }); +// ─── BUG-1 regression: duplicate Brave tools on repeated provider toggle ──── + +test("model_select re-enable does not duplicate Brave tools across toggle cycles", async () => { + const originalKey = process.env.BRAVE_API_KEY; + delete process.env.BRAVE_API_KEY; + + try { + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + // Cycle 1: Anthropic disables Brave tools + await pi.fire("model_select", { + type: "model_select", + model: { provider: "anthropic", name: "claude-sonnet-4-6" }, + previousModel: undefined, + source: "set", + }); + assert.ok(!pi.getActiveTools().includes("search-the-web"), "Disabled after 1st Anthropic select"); + + // Cycle 1: switch away re-enables + await pi.fire("model_select", { + type: "model_select", + model: { provider: "openai", name: "gpt-4o" }, + previousModel: { provider: "anthropic", name: "claude-sonnet-4-6" }, + source: "set", + }); + let active = pi.getActiveTools(); + assert.equal( + active.filter((t) => t === "search-the-web").length, 1, + "search-the-web exactly once after first re-enable" + ); + + // Cycle 2: Anthropic again + await pi.fire("model_select", { + type: "model_select", + model: { provider: "anthropic", name: "claude-sonnet-4-6" }, + previousModel: { provider: "openai", name: "gpt-4o" }, + source: "set", + }); + + // Cycle 2: switch away again — must NOT accumulate duplicates + await pi.fire("model_select", { + type: "model_select", + model: { provider: "openai", name: "gpt-4o" }, + previousModel: { provider: "anthropic", name: "claude-sonnet-4-6" }, + source: "set", + }); + active = pi.getActiveTools(); + assert.equal( + active.filter((t) => t === "search-the-web").length, 1, + "search-the-web exactly once after second re-enable (no duplicates)" + ); + assert.equal( + active.filter((t) => t === "search_and_read").length, 1, + "search_and_read exactly once (no duplicates)" + ); + } finally { + if (originalKey) process.env.BRAVE_API_KEY = originalKey; + else delete process.env.BRAVE_API_KEY; + } +}); + +// ─── BUG-3 regression: mock fire() must call all handlers, not just first ─── + +test("mock fire() calls all handlers for the same event", async () => { + const pi = createMockPI(); + const callOrder: number[] = []; + + // Register two handlers for the same event + pi.on("test_event", async () => { callOrder.push(1); return "first"; }); + pi.on("test_event", async () => { callOrder.push(2); return "second"; }); + + const result = await pi.fire("test_event", {}); + + assert.deepEqual(callOrder, [1, 2], "Both handlers should be called"); + assert.equal(result, "second", "Should return last non-undefined result"); +}); + +// ─── BUG-4 regression: no notification noise on session restore ───────────── + +test("model_select suppresses 'Native search active' notification on session restore", async () => { + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + await pi.fire("model_select", { + type: "model_select", + model: { provider: "anthropic", name: "claude-sonnet-4-6" }, + previousModel: undefined, + source: "restore", // session restore, not user action + }); + + const nativeNotif = pi.notifications.find( + (n) => n.message.includes("Native Anthropic web search active") + ); + assert.equal( + nativeNotif, undefined, + "Should NOT show 'Native search active' on session restore" + ); +}); + +test("model_select DOES show notification on explicit user set", async () => { + const pi = createMockPI(); + registerNativeSearchHooks(pi); + + await pi.fire("model_select", { + type: "model_select", + model: { provider: "anthropic", name: "claude-sonnet-4-6" }, + previousModel: undefined, + source: "set", + }); + + const nativeNotif = pi.notifications.find( + (n) => n.message.includes("Native Anthropic web search active") + ); + assert.ok(nativeNotif, "Should show notification on explicit 'set' source"); +}); + // ─── stripThinkingFromHistory tests ───────────────────────────────────────── test("stripThinkingFromHistory removes thinking from earlier assistant messages", () => {