Merge pull request #79 from FacuVCanale/feat/native-web-search
feat: native Anthropic web search via before_provider_request hook
This commit is contained in:
commit
108f6b4f1d
4 changed files with 810 additions and 26 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
157
src/resources/extensions/search-the-web/native-search.ts
Normal file
157
src/resources/extensions/search-the-web/native-search.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
/**
|
||||
* 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"];
|
||||
|
||||
/** 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;
|
||||
getActiveTools(): string[];
|
||||
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<Record<string, unknown>>
|
||||
): 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.
|
||||
*
|
||||
* Returns the isAnthropicProvider getter for testing.
|
||||
*/
|
||||
export function registerNativeSearchHooks(pi: NativeSearchPI): { getIsAnthropic: () => boolean } {
|
||||
let isAnthropicProvider = false;
|
||||
|
||||
// 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";
|
||||
|
||||
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.
|
||||
// Only add tools not already active to avoid duplicates on repeated toggles.
|
||||
const active = pi.getActiveTools();
|
||||
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 && event.source !== "restore") {
|
||||
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
|
||||
pi.on("before_provider_request", (event: any) => {
|
||||
const payload = event.payload as Record<string, unknown>;
|
||||
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;
|
||||
|
||||
// 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<Record<string, unknown>> | undefined;
|
||||
if (Array.isArray(messages)) {
|
||||
stripThinkingFromHistory(messages);
|
||||
}
|
||||
|
||||
if (!Array.isArray(payload.tools)) payload.tools = [];
|
||||
|
||||
let tools = payload.tools as Array<Record<string, unknown>>;
|
||||
|
||||
// Don't double-inject if already present
|
||||
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",
|
||||
});
|
||||
|
||||
return payload;
|
||||
});
|
||||
|
||||
// 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 (hasBrave) parts.push("Brave ✓");
|
||||
if (hasAnswers) parts.push("Answers ✓");
|
||||
if (hasJina) parts.push("Jina ✓");
|
||||
|
||||
ctx.ui.notify(parts.join(" · "), "info");
|
||||
});
|
||||
|
||||
return { getIsAnthropic: () => isAnthropicProvider };
|
||||
}
|
||||
636
src/tests/native-search.test.ts
Normal file
636
src/tests/native-search.test.ts
Normal file
|
|
@ -0,0 +1,636 @@
|
|||
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";
|
||||
|
||||
/**
|
||||
* 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<any>;
|
||||
} = {
|
||||
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) {
|
||||
let lastResult: any;
|
||||
for (const h of handlers) {
|
||||
if (h.event === event) {
|
||||
const result = await h.handler(eventData, ctx ?? mockCtx);
|
||||
if (result !== undefined) lastResult = result;
|
||||
}
|
||||
}
|
||||
return lastResult;
|
||||
},
|
||||
};
|
||||
|
||||
return pi;
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
test("before_provider_request injects web_search for claude models", async () => {
|
||||
const pi = createMockPI();
|
||||
registerNativeSearchHooks(pi);
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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("model_select shows 'Native Anthropic web search active' for Anthropic provider", 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 infoNotif = pi.notifications.find(
|
||||
(n) => n.level === "info" && n.message.includes("Native")
|
||||
);
|
||||
assert.ok(infoNotif, "Should notify about native search on Anthropic model_select");
|
||||
assert.ok(
|
||||
infoNotif!.message.includes("Native Anthropic web search active"),
|
||||
`Should say 'Native Anthropic web search active' — got: ${infoNotif!.message}`
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
try {
|
||||
const pi = createMockPI();
|
||||
registerNativeSearchHooks(pi);
|
||||
|
||||
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 for non-Anthropic without 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 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";
|
||||
|
||||
try {
|
||||
const pi = createMockPI();
|
||||
registerNativeSearchHooks(pi);
|
||||
|
||||
await pi.fire("session_start", { type: "session_start" });
|
||||
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
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<string, unknown> = {
|
||||
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<string, unknown> = {
|
||||
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;
|
||||
}
|
||||
});
|
||||
|
||||
// ─── 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", () => {
|
||||
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");
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue