singularity-forge/src/tests/native-search.test.ts
Mikael Hugo adb449d642 fix: consolidate extensions into sf, migrate kernel.ts, fix test suite
- Fold sf-usage-bar, sf-notify, sf-inturn-guard, sf-permissions,
  slash-commands into sf extension (ui/, notifications/, guards/,
  permissions/, commands/legacy/)
- Delete vectordrive extension
- Migrate uok/kernel.js to TypeScript (kernel.ts) with full interfaces
- Add allowJs/checkJs:false to tsconfig.resources.json for incremental TS migration
- Add symlink dedup to extension-discovery.ts (seenRealPaths Set)
- Add before_provider_request delegate back to native-search.js so
  session budget tests exercise the middleware end-to-end
- Fix parseSfNativeTools() to return all SF manifest tools (drop sf_ filter)
- Fix test assertions: plan_milestone/complete_task/validate_milestone
- Remove subagent from app-smoke.test.ts (folded into sf/subagent/)
- Remove sf-permissions/sf-inturn-guard/subagent from features-inventory test
- Fix resolveSearchProvider autonomous mode test to pass 'auto' explicitly
- Remove legacy /clear slash command (conflicts with built-in clear_terminal)
- Update web-command-parity-contract.test.ts for clear removal

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-11 02:40:52 +02:00

1367 lines
40 KiB
TypeScript

import assert from "node:assert/strict";
import { afterEach, test } from "vitest";
import {
CUSTOM_SEARCH_TOOL_NAMES,
MAX_NATIVE_SEARCHES_PER_SESSION,
stripThinkingFromHistory,
webSearchMiddleware,
} from "@singularity-forge/coding-agent";
import {
BRAVE_TOOL_NAMES,
registerNativeSearchHooks,
} from "../resources/extensions/search-the-web/native-search.ts";
import {
getMiniMaxSearchApiKey,
resolveSearchProvider,
} from "../resources/extensions/search-the-web/provider.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",
"google_search",
"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: {
handlers: MockHandler[];
notifications: typeof notifications;
mockCtx: typeof mockCtx;
fire(event: string, eventData: any, ctx?: any): Promise<any>;
on(event: string, handler: (...args: any[]) => any): void;
getActiveTools(): string[];
setActiveTools(tools: string[]): void;
} = {
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 ──────────────────────────────────────────────────────────────────
// ─── webSearchMiddleware.applyToPayload tests ────────────────────────────────
// before_provider_request injection runs natively in sdk.ts; tests call the
// middleware directly instead of routing through the extension hook.
test("applyToPayload injects web_search for Anthropic provider", async () => {
const pi = createMockPI();
registerNativeSearchHooks(pi); // resets session counter
const payload: Record<string, unknown> = {
model: "claude-sonnet-4-6-20250514",
tools: [{ name: "bash", type: "custom" }],
};
const result = webSearchMiddleware.applyToPayload(payload, { provider: "anthropic" });
const tools = (result as any)?.tools ?? payload.tools;
const nativeTool = (tools as any[]).find(
(t: any) => t.type === "web_search_20250305",
);
assert.ok(nativeTool, "Should inject web_search_20250305 tool");
assert.equal(
(tools as any[]).length,
2,
"Should have original + injected tool",
);
assert.equal(
nativeTool.max_uses,
5,
"Should set max_uses to 5 to prevent search loops (#817)",
);
});
test("applyToPayload injects web_search based on claude model name heuristic", async () => {
const pi = createMockPI();
registerNativeSearchHooks(pi);
const payload: Record<string, unknown> = {
model: "claude-opus-4-6",
tools: [
{ name: "bash", type: "custom" },
{ name: "search-the-web", type: "function" },
{ name: "google_search", type: "function" },
],
};
const result = webSearchMiddleware.applyToPayload(payload);
const tools = ((result as any)?.tools ?? payload.tools) as any[];
const names = tools.map((t: any) => t.name ?? t.type);
assert.ok(
names.includes("web_search"),
"Should inject native web_search based on model name",
);
assert.ok(!names.includes("search-the-web"), "Should remove search-the-web");
assert.ok(!names.includes("google_search"), "Should remove google_search");
assert.ok(names.includes("bash"), "Should keep non-search tools");
});
test("applyToPayload does NOT inject for non-claude model names", async () => {
const pi = createMockPI();
registerNativeSearchHooks(pi);
const payload: Record<string, unknown> = {
model: "gpt-4o",
tools: [{ name: "bash", type: "custom" }],
};
const result = webSearchMiddleware.applyToPayload(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("applyToPayload does NOT inject for claude model when provider is non-Anthropic", async () => {
const pi = createMockPI();
registerNativeSearchHooks(pi);
const payload: Record<string, unknown> = {
model: "claude-sonnet-4-6-20250514",
tools: [{ name: "bash", type: "custom" }],
};
const result = webSearchMiddleware.applyToPayload(payload, { provider: "copilot" });
assert.equal(
result,
undefined,
"Should not modify payload for non-Anthropic provider",
);
const tools = payload.tools as any[];
assert.equal(
tools.length,
1,
"Should not inject web_search for non-Anthropic provider",
);
assert.ok(
!tools.some((t: any) => t.type === "web_search_20250305"),
"web_search_20250305 must NOT be present for non-Anthropic providers",
);
});
// ─── Issue #444 regression: Copilot claude-* model ───────────────────────────
test("applyToPayload does NOT inject when provider is github-copilot", async () => {
const pi = createMockPI();
registerNativeSearchHooks(pi);
const payload: Record<string, unknown> = {
model: "claude-sonnet-4-6-20250514",
tools: [{ name: "bash", type: "custom" }],
};
const result = webSearchMiddleware.applyToPayload(payload, { provider: "github-copilot" });
assert.equal(
result,
undefined,
"Should not modify payload when provider is Copilot",
);
const tools = payload.tools as any[];
assert.equal(
tools.length,
1,
"Should not inject web_search for Copilot provider",
);
assert.ok(
!tools.some((t: any) => t.type === "web_search_20250305"),
"web_search_20250305 must NOT be present for Copilot",
);
});
test("applyToPayload DOES inject when provider is anthropic", async () => {
const pi = createMockPI();
registerNativeSearchHooks(pi);
const payload: Record<string, unknown> = {
model: "claude-sonnet-4-6-20250514",
tools: [{ name: "bash", type: "custom" }],
};
const result = webSearchMiddleware.applyToPayload(payload, { provider: "anthropic" });
const tools = ((result as any)?.tools ?? payload.tools) as any[];
assert.ok(
tools.some((t: any) => t.type === "web_search_20250305"),
"Should inject web_search when provider is anthropic",
);
});
test("applyToPayload 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 = webSearchMiddleware.applyToPayload(payload, { provider: "anthropic" });
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("applyToPayload creates tools array if missing", async () => {
const pi = createMockPI();
registerNativeSearchHooks(pi);
const payload: Record<string, unknown> = {
model: "claude-haiku-4-5-20251001",
};
const result = webSearchMiddleware.applyToPayload(payload, { provider: "anthropic" });
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");
assert.equal(
(tools as any[])[0].max_uses,
5,
"Should include max_uses limit",
);
});
test("applyToPayload skips when payload is falsy", async () => {
const pi = createMockPI();
registerNativeSearchHooks(pi);
const result = webSearchMiddleware.applyToPayload(null);
assert.equal(result, undefined, "Should return undefined for null payload");
});
test("model_select disables Brave tools when Anthropic + no BRAVE_API_KEY", async (_t) => {
const originalKey = process.env.BRAVE_API_KEY;
delete process.env.BRAVE_API_KEY;
afterEach(() => {
if (originalKey) process.env.BRAVE_API_KEY = originalKey;
else delete process.env.BRAVE_API_KEY;
});
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("google_search"),
"google_search should be disabled",
);
assert.ok(active.includes("fetch_page"), "fetch_page should remain active");
assert.ok(active.includes("bash"), "Other tools should remain active");
});
test("model_select disables all custom search tools when Anthropic even with BRAVE_API_KEY", async (_t) => {
const originalKey = process.env.BRAVE_API_KEY;
process.env.BRAVE_API_KEY = "test-key";
afterEach(() => {
if (originalKey) process.env.BRAVE_API_KEY = originalKey;
else delete process.env.BRAVE_API_KEY;
});
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 for Anthropic",
);
assert.ok(
!active.includes("search_and_read"),
"search_and_read should be disabled for Anthropic",
);
assert.ok(
!active.includes("google_search"),
"google_search should be disabled for Anthropic",
);
assert.ok(active.includes("fetch_page"), "fetch_page should remain active");
});
test("model_select re-enables Brave tools when switching away from Anthropic", async (_t) => {
const originalKey = process.env.BRAVE_API_KEY;
delete process.env.BRAVE_API_KEY;
afterEach(() => {
if (originalKey) process.env.BRAVE_API_KEY = originalKey;
else delete process.env.BRAVE_API_KEY;
});
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",
);
assert.ok(
active.includes("google_search"),
"google_search should be re-enabled",
);
});
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 (_t) => {
const keys = [
"BRAVE_API_KEY",
"TAVILY_API_KEY",
"MINIMAX_API_KEY",
"MINIMAX_CODE_PLAN_KEY",
"MINIMAX_CODING_API_KEY",
"SERPER_API_KEY",
"EXA_API_KEY",
"OLLAMA_API_KEY",
];
const originals: Record<string, string | undefined> = {};
for (const k of keys) {
originals[k] = process.env[k];
delete process.env[k];
}
afterEach(() => {
for (const k of keys) {
if (originals[k] !== undefined) process.env[k] = originals[k];
else delete process.env[k];
}
});
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}`,
);
});
test("session_start resets search count and shows no startup notification", async () => {
const pi = createMockPI();
registerNativeSearchHooks(pi);
await pi.fire("session_start", { type: "session_start" });
// Tool status is now shown in the welcome screen bar layout — no notification on session_start
const infoNotif = pi.notifications.find(
(n) => n.level === "info" && n.message.includes("v4"),
);
assert.equal(
infoNotif,
undefined,
"Should NOT emit a v4 startup notification (welcome screen handles this)",
);
});
test("BRAVE_TOOL_NAMES contains expected tool names", () => {
assert.deepEqual(BRAVE_TOOL_NAMES, ["search-the-web", "search_and_read"]);
});
test("CUSTOM_SEARCH_TOOL_NAMES contains all custom search tools", () => {
assert.deepEqual(CUSTOM_SEARCH_TOOL_NAMES, [
"search-the-web",
"search_and_read",
"google_search",
]);
});
test("before_provider_request removes Brave tools from payload when no BRAVE_API_KEY", async (_t) => {
const originalKey = process.env.BRAVE_API_KEY;
delete process.env.BRAVE_API_KEY;
afterEach(() => {
if (originalKey) process.env.BRAVE_API_KEY = originalKey;
else delete process.env.BRAVE_API_KEY;
});
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 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: "google_search", 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("google_search"),
"google_search 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",
);
});
test("before_provider_request removes all custom search tools from payload even with BRAVE_API_KEY", async (_t) => {
const originalKey = process.env.BRAVE_API_KEY;
process.env.BRAVE_API_KEY = "test-key";
afterEach(() => {
if (originalKey) process.env.BRAVE_API_KEY = originalKey;
else delete process.env.BRAVE_API_KEY;
});
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 payload: Record<string, unknown> = {
model: "claude-sonnet-4-6-20250514",
tools: [
{ name: "search-the-web", type: "function" },
{ name: "search_and_read", type: "function" },
{ name: "google_search", 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 for Anthropic",
);
assert.ok(
!names.includes("search_and_read"),
"search_and_read should be removed for Anthropic",
);
assert.ok(
!names.includes("google_search"),
"google_search should be removed for Anthropic",
);
assert.ok(names.includes("fetch_page"), "fetch_page should remain");
assert.ok(
names.includes("web_search"),
"native web_search should be injected",
);
});
// ─── BUG-1 regression: duplicate Brave tools on repeated provider toggle ────
test("model_select re-enable does not duplicate Brave tools across toggle cycles", async (_t) => {
const originalKey = process.env.BRAVE_API_KEY;
delete process.env.BRAVE_API_KEY;
afterEach(() => {
if (originalKey) process.env.BRAVE_API_KEY = originalKey;
else delete process.env.BRAVE_API_KEY;
});
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)",
);
assert.equal(
active.filter((t) => t === "google_search").length,
1,
"google_search exactly once (no duplicates)",
);
});
// ─── 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");
});
// ─── Session-level search budget (#1309) ────────────────────────────────────
test("session search budget: max_uses decreases as history accumulates search results", 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",
});
// Simulate a conversation with 10 web_search_tool_result blocks in history
const messages: any[] = [
{ role: "user", content: "research this topic" },
{
role: "assistant",
content: [
{ type: "web_search_tool_result", tool_use_id: "ws1", content: [] },
{ type: "web_search_tool_result", tool_use_id: "ws2", content: [] },
{ type: "web_search_tool_result", tool_use_id: "ws3", content: [] },
{ type: "web_search_tool_result", tool_use_id: "ws4", content: [] },
{ type: "web_search_tool_result", tool_use_id: "ws5", content: [] },
{ type: "text", text: "Here are some results..." },
],
},
{ role: "user", content: "continue" },
{
role: "assistant",
content: [
{ type: "web_search_tool_result", tool_use_id: "ws6", content: [] },
{ type: "web_search_tool_result", tool_use_id: "ws7", content: [] },
{ type: "web_search_tool_result", tool_use_id: "ws8", content: [] },
{ type: "web_search_tool_result", tool_use_id: "ws9", content: [] },
{ type: "web_search_tool_result", tool_use_id: "ws10", content: [] },
{ type: "text", text: "More results..." },
],
},
{ role: "user", content: "keep going" },
];
const payload: Record<string, unknown> = {
model: "claude-sonnet-4-6-20250514",
tools: [{ name: "bash", type: "custom" }],
messages,
};
const result = await pi.fire("before_provider_request", {
type: "before_provider_request",
payload,
});
const tools = ((result as any)?.tools ?? payload.tools) as any[];
const nativeTool = tools.find((t: any) => t.type === "web_search_20250305");
assert.ok(nativeTool, "Should still inject web_search when budget remaining");
// 15 - 10 = 5 remaining, min(5, 5) = 5
assert.equal(nativeTool.max_uses, 5, "Should cap at min(5, remaining)");
});
test("session search budget: reduces max_uses when close to limit", 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",
});
// 13 search results in history → only 2 remaining
const searchBlocks = Array.from({ length: 13 }, (_, i) => ({
type: "web_search_tool_result",
tool_use_id: `ws${i}`,
content: [],
}));
const messages: any[] = [
{ role: "user", content: "research" },
{
role: "assistant",
content: [...searchBlocks, { type: "text", text: "results" }],
},
{ role: "user", content: "more" },
];
const payload: Record<string, unknown> = {
model: "claude-sonnet-4-6-20250514",
tools: [{ name: "bash", type: "custom" }],
messages,
};
const result = await pi.fire("before_provider_request", {
type: "before_provider_request",
payload,
});
const tools = ((result as any)?.tools ?? payload.tools) as any[];
const nativeTool = tools.find((t: any) => t.type === "web_search_20250305");
assert.ok(nativeTool, "Should still inject when budget > 0");
// 15 - 13 = 2 remaining
assert.equal(
nativeTool.max_uses,
2,
"Should reduce max_uses to remaining budget",
);
});
test("session search budget: omits web_search tool when budget exhausted", 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",
});
// 15+ search results in history → budget exhausted
const searchBlocks = Array.from(
{ length: MAX_NATIVE_SEARCHES_PER_SESSION },
(_, i) => ({
type: "web_search_tool_result",
tool_use_id: `ws${i}`,
content: [],
}),
);
const messages: any[] = [
{ role: "user", content: "research" },
{
role: "assistant",
content: [...searchBlocks, { type: "text", text: "results" }],
},
{ role: "user", content: "more" },
];
const payload: Record<string, unknown> = {
model: "claude-sonnet-4-6-20250514",
tools: [{ name: "bash", type: "custom" }],
messages,
};
const result = await pi.fire("before_provider_request", {
type: "before_provider_request",
payload,
});
const tools = ((result as any)?.tools ?? payload.tools) as any[];
const nativeTool = tools.find((t: any) => t.type === "web_search_20250305");
assert.equal(
nativeTool,
undefined,
"Should NOT inject web_search when budget exhausted (#1309)",
);
// Other tools should remain
assert.ok(
tools.some((t: any) => t.name === "bash"),
"Non-search tools should remain",
);
});
test("session search budget: resets on session_start", 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",
});
// First session: exhaust budget
const searchBlocks = Array.from(
{ length: MAX_NATIVE_SEARCHES_PER_SESSION },
(_, i) => ({
type: "web_search_tool_result",
tool_use_id: `ws${i}`,
content: [],
}),
);
let payload: Record<string, unknown> = {
model: "claude-sonnet-4-6-20250514",
tools: [{ name: "bash", type: "custom" }],
messages: [
{ role: "user", content: "research" },
{ role: "assistant", content: [...searchBlocks] },
{ role: "user", content: "more" },
],
};
await pi.fire("before_provider_request", {
type: "before_provider_request",
payload,
});
let tools = payload.tools as any[];
assert.ok(
!tools.some((t: any) => t.type === "web_search_20250305"),
"Budget should be exhausted",
);
// New session starts — counter resets
await pi.fire("session_start", { type: "session_start" });
// New request with no history — full budget available
payload = {
model: "claude-sonnet-4-6-20250514",
tools: [{ name: "bash", type: "custom" }],
messages: [{ role: "user", content: "new research" }],
};
const result = await pi.fire("before_provider_request", {
type: "before_provider_request",
payload,
});
tools = ((result as any)?.tools ?? payload.tools) as any[];
const nativeTool = tools.find((t: any) => t.type === "web_search_20250305");
assert.ok(nativeTool, "Should inject web_search after session reset");
assert.equal(
nativeTool.max_uses,
5,
"Should have full per-turn budget after reset",
);
});
test("MAX_NATIVE_SEARCHES_PER_SESSION is exported and equals 15", () => {
assert.equal(
MAX_NATIVE_SEARCHES_PER_SESSION,
15,
"Session budget should be 15 (#1309)",
);
});
test("session search budget: survives context compaction (high-water mark)", 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",
});
// First request: history has 12 web_search_tool_result blocks
const searchBlocks = Array.from({ length: 12 }, (_, i) => ({
type: "web_search_tool_result",
tool_use_id: `ws${i}`,
content: [],
}));
let payload: Record<string, unknown> = {
model: "claude-sonnet-4-6-20250514",
tools: [{ name: "bash", type: "custom" }],
messages: [
{
role: "user",
content: [{ type: "text", text: "search" }, ...searchBlocks],
},
],
};
await pi.fire("before_provider_request", {
type: "before_provider_request",
payload,
});
let tools = payload.tools as any[];
let nativeTool = tools.find((t: any) => t.type === "web_search_20250305");
assert.ok(nativeTool, "Should still inject web_search with 12/15 used");
assert.equal(nativeTool.max_uses, 3, "Should have 3 remaining (15 - 12)");
// Second request: context was compacted — search blocks gone from history.
// Without high-water mark, the budget would reset to 15.
payload = {
model: "claude-sonnet-4-6-20250514",
tools: [{ name: "bash", type: "custom" }],
messages: [
{ role: "user", content: "compacted context — no search blocks" },
],
};
await pi.fire("before_provider_request", {
type: "before_provider_request",
payload,
});
tools = payload.tools as any[];
nativeTool = tools.find((t: any) => t.type === "web_search_20250305");
assert.ok(
nativeTool,
"Should still inject web_search with 12/15 used (high-water mark)",
);
assert.equal(
nativeTool.max_uses,
3,
"High-water mark should preserve 12 — only 3 remaining",
);
});
// ─── 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");
});
// ─── Minimax search tests (R115) ────────────────────────────────────────────
test("getMiniMaxSearchApiKey returns MINIMAX_CODE_PLAN_KEY when set", async (_t) => {
const original = process.env.MINIMAX_CODE_PLAN_KEY;
const original2 = process.env.MINIMAX_CODING_API_KEY;
const original3 = process.env.MINIMAX_API_KEY;
afterEach(() => {
process.env.MINIMAX_CODE_PLAN_KEY = original;
process.env.MINIMAX_CODING_API_KEY = original2;
process.env.MINIMAX_API_KEY = original3;
});
process.env.MINIMAX_CODE_PLAN_KEY = "code-plan-key";
process.env.MINIMAX_CODING_API_KEY = "coding-key";
process.env.MINIMAX_API_KEY = "api-key";
// Should return first in priority order
assert.equal(
getMiniMaxSearchApiKey(),
"code-plan-key",
"Should return MINIMAX_CODE_PLAN_KEY (highest priority)",
);
});
test("getMiniMaxSearchApiKey falls back to MINIMAX_CODING_API_KEY", async (_t) => {
const original = process.env.MINIMAX_CODE_PLAN_KEY;
const original2 = process.env.MINIMAX_CODING_API_KEY;
const original3 = process.env.MINIMAX_API_KEY;
afterEach(() => {
process.env.MINIMAX_CODE_PLAN_KEY = original;
process.env.MINIMAX_CODING_API_KEY = original2;
process.env.MINIMAX_API_KEY = original3;
});
delete process.env.MINIMAX_CODE_PLAN_KEY;
process.env.MINIMAX_CODING_API_KEY = "coding-key";
process.env.MINIMAX_API_KEY = "api-key";
assert.equal(
getMiniMaxSearchApiKey(),
"coding-key",
"Should return MINIMAX_CODING_API_KEY when CODE_PLAN_KEY is unset",
);
});
test("getMiniMaxSearchApiKey falls back to MINIMAX_API_KEY", async (_t) => {
const original = process.env.MINIMAX_CODE_PLAN_KEY;
const original2 = process.env.MINIMAX_CODING_API_KEY;
const original3 = process.env.MINIMAX_API_KEY;
afterEach(() => {
process.env.MINIMAX_CODE_PLAN_KEY = original;
process.env.MINIMAX_CODING_API_KEY = original2;
process.env.MINIMAX_API_KEY = original3;
});
delete process.env.MINIMAX_CODE_PLAN_KEY;
delete process.env.MINIMAX_CODING_API_KEY;
process.env.MINIMAX_API_KEY = "api-key";
assert.equal(
getMiniMaxSearchApiKey(),
"api-key",
"Should return MINIMAX_API_KEY when higher priority keys are unset",
);
});
test("getMiniMaxSearchApiKey returns empty string when no keys set", async (_t) => {
const original = process.env.MINIMAX_CODE_PLAN_KEY;
const original2 = process.env.MINIMAX_CODING_API_KEY;
const original3 = process.env.MINIMAX_API_KEY;
afterEach(() => {
process.env.MINIMAX_CODE_PLAN_KEY = original;
process.env.MINIMAX_CODING_API_KEY = original2;
process.env.MINIMAX_API_KEY = original3;
});
delete process.env.MINIMAX_CODE_PLAN_KEY;
delete process.env.MINIMAX_CODING_API_KEY;
delete process.env.MINIMAX_API_KEY;
assert.equal(
getMiniMaxSearchApiKey(),
"",
"Should return empty string when no Minimax keys are set",
);
});
test("resolveSearchProvider returns minimax when MINIMAX_API_KEY is set and preference is auto", async (_t) => {
const original = process.env.MINIMAX_CODE_PLAN_KEY;
const original2 = process.env.MINIMAX_CODING_API_KEY;
const original3 = process.env.MINIMAX_API_KEY;
const originalTavily = process.env.TAVILY_API_KEY;
afterEach(() => {
process.env.MINIMAX_CODE_PLAN_KEY = original;
process.env.MINIMAX_CODING_API_KEY = original2;
process.env.MINIMAX_API_KEY = original3;
process.env.TAVILY_API_KEY = originalTavily;
});
delete process.env.MINIMAX_CODE_PLAN_KEY;
delete process.env.MINIMAX_CODING_API_KEY;
delete process.env.TAVILY_API_KEY;
process.env.MINIMAX_API_KEY = "test-minimax-key";
// With no Tavily key, minimax should be selected in autonomous mode
const result = resolveSearchProvider();
assert.equal(
result,
"minimax",
"Should return minimax when Minimax key exists and Tavily does not",
);
});
test("resolveSearchProvider prefers tavily over minimax in autonomous mode", async (_t) => {
const original = process.env.TAVILY_API_KEY;
const original2 = process.env.MINIMAX_API_KEY;
afterEach(() => {
process.env.TAVILY_API_KEY = original;
process.env.MINIMAX_API_KEY = original2;
});
process.env.TAVILY_API_KEY = "test-tavily-key";
process.env.MINIMAX_API_KEY = "test-minimax-key";
// In auto mode (no explicit preference), tavily wins by registry order.
const result = resolveSearchProvider("auto");
assert.equal(
result,
"tavily",
"Should prefer tavily over minimax in autonomous mode",
);
});
test("resolveSearchProvider with explicit minimax preference returns minimax when key exists", async (_t) => {
const original = process.env.MINIMAX_API_KEY;
const originalTavily = process.env.TAVILY_API_KEY;
afterEach(() => {
process.env.MINIMAX_API_KEY = original;
process.env.TAVILY_API_KEY = originalTavily;
});
process.env.MINIMAX_API_KEY = "test-minimax-key";
delete process.env.TAVILY_API_KEY;
const result = resolveSearchProvider("minimax");
assert.equal(
result,
"minimax",
"Should return minimax when explicitly preferred and key exists",
);
});
test("resolveSearchProvider minimax preference falls back when key missing", async (_t) => {
const original = process.env.MINIMAX_API_KEY;
const originalCP = process.env.MINIMAX_CODE_PLAN_KEY;
const originalCA = process.env.MINIMAX_CODING_API_KEY;
const originalTavily = process.env.TAVILY_API_KEY;
const originalBrave = process.env.BRAVE_API_KEY;
afterEach(() => {
process.env.MINIMAX_API_KEY = original;
process.env.MINIMAX_CODE_PLAN_KEY = originalCP;
process.env.MINIMAX_CODING_API_KEY = originalCA;
process.env.TAVILY_API_KEY = originalTavily;
process.env.BRAVE_API_KEY = originalBrave;
});
delete process.env.MINIMAX_API_KEY;
delete process.env.MINIMAX_CODE_PLAN_KEY;
delete process.env.MINIMAX_CODING_API_KEY;
delete process.env.TAVILY_API_KEY;
process.env.BRAVE_API_KEY = "test-brave-key";
// With explicit minimax preference but no key, should fall back to brave
const result = resolveSearchProvider("minimax");
assert.equal(
result,
"brave",
"Should fall back to brave when minimax preference is set but key is missing",
);
});
test("resolveSearchProvider returns null when no keys set", async (_t) => {
const keys = [
"TAVILY_API_KEY",
"MINIMAX_API_KEY",
"MINIMAX_CODE_PLAN_KEY",
"MINIMAX_CODING_API_KEY",
"BRAVE_API_KEY",
"SERPER_API_KEY",
"EXA_API_KEY",
"OLLAMA_API_KEY",
];
const originals: Record<string, string | undefined> = {};
for (const k of keys) {
originals[k] = process.env[k];
delete process.env[k];
}
afterEach(() => {
for (const k of keys) {
if (originals[k] !== undefined) process.env[k] = originals[k];
else delete process.env[k];
}
});
const result = resolveSearchProvider();
assert.equal(
result,
null,
"Should return null when no search provider keys are set",
);
});