diff --git a/src/extension-utils.ts b/src/extension-utils.ts new file mode 100644 index 0000000..2e5d7cd --- /dev/null +++ b/src/extension-utils.ts @@ -0,0 +1,20 @@ +import * as vscode from "vscode"; + +/** + * Returns the fsPath of the workspace folder that owns the active editor's + * file. Falls back to workspaceFolders[0] when no editor is active or the + * active file isn't inside any workspace folder. Returns undefined when no + * workspace folders are open at all. + */ +export function resolveActiveWorkspaceFolder(): string | undefined { + const folders = vscode.workspace.workspaceFolders; + if (!folders || folders.length === 0) return undefined; + + const uri = vscode.window.activeTextEditor?.document.uri; + if (uri) { + const folder = vscode.workspace.getWorkspaceFolder(uri); + if (folder) return folder.uri.fsPath; + } + + return folders[0].uri.fsPath; +} diff --git a/src/extension.ts b/src/extension.ts index 056a6d3..0a197a3 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -30,6 +30,7 @@ import { fileSearchTool } from "./tools/file-search"; import { fetchUrlTool } from "./tools/fetch-url"; import { runTestsTool } from "./tools/run-tests"; import { browserTool, closeBrowser } from "./tools/browser"; +import { createWebSearchTool } from "./tools/web-search"; import { AgentController, type AgentMode } from "./agent/agent-controller"; import { ChatViewProvider } from "./ui/chat-view-provider"; import { ChampInlineCompletionProvider } from "./completion/inline-provider"; @@ -99,6 +100,7 @@ import { DiffOverlayController } from "./ui/diff-overlay-controller"; import { CircuitBreaker } from "./providers/circuit-breaker"; import { FallbackProvider } from "./providers/fallback-provider"; import { RateLimitedProvider } from "./providers/rate-limited-provider"; +import { resolveActiveWorkspaceFolder } from "./extension-utils"; import { AuditLog } from "./observability/audit-log"; import { ChampServer } from "./server/champ-server"; @@ -195,6 +197,7 @@ export async function activate( toolRegistry.register(runTestsTool); toolRegistry.register(generateDocTool); toolRegistry.register(browserTool); + toolRegistry.register(createWebSearchTool(context.secrets)); toolRegistry.register( createCodebaseSearchTool(() => indexingService ?? null), ); @@ -1395,14 +1398,15 @@ export async function activate( ); }), vscode.commands.registerCommand("champ.generateConfig", async () => { - if (!workspaceRoot) { + const activeFolder = resolveActiveWorkspaceFolder() ?? workspaceRoot; + if (!activeFolder) { void vscode.window.showErrorMessage( "Champ: open a workspace folder before generating a config file.", ); return; } const targetUri = vscode.Uri.file( - path.join(workspaceRoot, ".champ", "config.yaml"), + path.join(activeFolder, ".champ", "config.yaml"), ); // If file exists, just open it (don't prompt to overwrite). try { @@ -1416,7 +1420,7 @@ export async function activate( const template = generateDefaultConfigYaml(); try { await vscode.workspace.fs.createDirectory( - vscode.Uri.file(path.join(workspaceRoot, ".champ")), + vscode.Uri.file(path.join(activeFolder, ".champ")), ); } catch { // Directory may already exist. @@ -1515,13 +1519,14 @@ export async function activate( // `provider:` line. Comments and the rest of the file are // preserved. The file watcher fires loadProvider() which // broadcasts a fresh providerStatus to the chat view. - if (!workspaceRoot) { + const activeFolder = resolveActiveWorkspaceFolder() ?? workspaceRoot; + if (!activeFolder) { void vscode.window.showErrorMessage( "Champ: cannot switch model without an open workspace.", ); return; } - const yamlPath = path.join(workspaceRoot, ".champ", "config.yaml"); + const yamlPath = path.join(activeFolder, ".champ", "config.yaml"); const yamlUri = vscode.Uri.file(yamlPath); let text: string; let configExisted = true; @@ -1535,7 +1540,7 @@ export async function activate( const template = generateDefaultConfigYaml(); try { await vscode.workspace.fs.createDirectory( - vscode.Uri.file(path.join(workspaceRoot, ".champ")), + vscode.Uri.file(path.join(activeFolder, ".champ")), ); } catch { /* dir already exists */ @@ -1582,15 +1587,16 @@ export async function activate( ); return; } - if (!workspaceRoot) { + const activeFolder = resolveActiveWorkspaceFolder() ?? workspaceRoot; + if (!activeFolder) { void vscode.window.showErrorMessage( "Champ: open a workspace folder before creating a config file.", ); return; } - const targetDir = vscode.Uri.file(path.join(workspaceRoot, ".champ")); + const targetDir = vscode.Uri.file(path.join(activeFolder, ".champ")); const targetUri = vscode.Uri.file( - path.join(workspaceRoot, ".champ", "config.yaml"), + path.join(activeFolder, ".champ", "config.yaml"), ); try { await vscode.workspace.fs.createDirectory(targetDir); @@ -1791,13 +1797,14 @@ export async function activate( }, action: "add" | "delete", ) => { - if (!workspaceRoot) { + const activeMcpFolder = resolveActiveWorkspaceFolder() ?? workspaceRoot; + if (!activeMcpFolder) { void vscode.window.showErrorMessage( "Champ: open a workspace to configure MCP servers.", ); return; } - const configPath = path.join(workspaceRoot, ".champ", "config.yaml"); + const configPath = path.join(activeMcpFolder, ".champ", "config.yaml"); let rawConfig = ""; try { rawConfig = new TextDecoder().decode( @@ -1895,7 +1902,9 @@ export async function activate( const newServer = buildMcpServerConfig(entry, resolvedEnv); - if (!workspaceRoot) { + const activeMcpInstallFolder = + resolveActiveWorkspaceFolder() ?? workspaceRoot; + if (!activeMcpInstallFolder) { void vscode.window.showErrorMessage( "Champ: open a workspace to install MCP servers.", ); @@ -1903,7 +1912,7 @@ export async function activate( } const configPath = path.join( - workspaceRoot, + activeMcpInstallFolder, ".champ", "config.yaml", ); @@ -2953,8 +2962,9 @@ export async function activate( * crash activation. */ const resolveConfig = async (): Promise => { - const workspacePath = workspaceRoot - ? path.join(workspaceRoot, ".champ", "config.yaml") + const activeFolder = resolveActiveWorkspaceFolder(); + const workspacePath = activeFolder + ? path.join(activeFolder, ".champ", "config.yaml") : null; const userPath = path.join(os.homedir(), ".champ", "config.yaml"); @@ -3037,8 +3047,9 @@ export async function activate( yamlConfig = await resolveConfig(); cachedYamlConfig = yamlConfig; // Load project rules from .champ/rules/*.md - if (workspaceRoot) { - const rulesDir = path.join(workspaceRoot, ".champ", "rules"); + const activeRulesFolder = resolveActiveWorkspaceFolder(); + if (activeRulesFolder) { + const rulesDir = path.join(activeRulesFolder, ".champ", "rules"); rulesEngine.clearProjectRules(); await rulesEngine.loadRulesFromDirectory(rulesDir).catch((err) => { console.warn(`Champ: failed to load rules from ${rulesDir}:`, err); @@ -3673,36 +3684,47 @@ export async function activate( }), ); - // Watch .champ/config.yaml in the workspace for live reload. Created, - // changed, or deleted — any of those should trigger a provider reload - // since the file is the source of truth when it exists. - if (workspaceRoot) { - const yamlWatcher = vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern(workspaceRoot, ".champ/config.yaml"), - ); + // Reload provider when the active editor moves to a different workspace + // folder so that the correct .champ/config.yaml is used per project. + let _lastActiveFolder: string | undefined = workspaceRoot ?? undefined; + context.subscriptions.push( + vscode.window.onDidChangeActiveTextEditor(async (editor) => { + if (!editor) return; + const newFolder = resolveActiveWorkspaceFolder(); + if (newFolder !== _lastActiveFolder) { + _lastActiveFolder = newFolder; + await loadProvider(); + } + }), + ); + + // Watch .champ/config.yaml in every open workspace folder and in ~/.champ/ + // for live reload. Created, changed, or deleted — any triggers a provider reload. + { + const yamlWatchers: vscode.FileSystemWatcher[] = []; + const watchedRoots = [ + ...(vscode.workspace.workspaceFolders?.map((f) => f.uri.fsPath) ?? + (workspaceRoot ? [workspaceRoot] : [])), + os.homedir(), + ]; let configReloadTimer: ReturnType | undefined; - (yamlWatcher.onDidChange(() => { + const debouncedReload = () => { if (configReloadTimer) clearTimeout(configReloadTimer); configReloadTimer = setTimeout(() => { configReloadTimer = undefined; void loadProvider(); - }, 300); // 300ms debounce - }), - yamlWatcher.onDidCreate(() => { - if (configReloadTimer) clearTimeout(configReloadTimer); - configReloadTimer = setTimeout(() => { - configReloadTimer = undefined; - void loadProvider(); - }, 300); - }), - yamlWatcher.onDidDelete(() => { - if (configReloadTimer) clearTimeout(configReloadTimer); - configReloadTimer = setTimeout(() => { - configReloadTimer = undefined; - void loadProvider(); - }, 300); - })); - context.subscriptions.push(yamlWatcher); + }, 300); + }; + for (const root of watchedRoots) { + const w = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(root, ".champ/config.yaml"), + ); + w.onDidChange(debouncedReload); + w.onDidCreate(debouncedReload); + w.onDidDelete(debouncedReload); + yamlWatchers.push(w); + } + context.subscriptions.push(...yamlWatchers); } // ---- Team Builder and Rules Editor commands ------------------------- diff --git a/src/tools/web-search.ts b/src/tools/web-search.ts new file mode 100644 index 0000000..54753d3 --- /dev/null +++ b/src/tools/web-search.ts @@ -0,0 +1,227 @@ +/** + * web_search tool: Search the internet using the Brave Search API. + * + * Enables the agent to query the web for information to supplement code + * context and provide current data. Requires a Brave Search API key stored + * in VS Code's SecretStorage. + */ +import type { Tool, ToolResult, ToolExecutionContext } from "./types"; + +const BRAVE_SEARCH_API_ENDPOINT = + "https://api.search.brave.com/res/v1/web/search"; +const MAX_RESULTS = 10; +const DEFAULT_RESULTS = 5; +const TIMEOUT_MS = 15_000; + +/** + * Search result from the Brave Search API. + */ +export interface SearchResult { + title: string; + url: string; + snippet: string; + source?: string; +} + +/** + * Input parameters for the web search tool. + */ +export interface WebSearchInput { + query: string; + count?: number; // default: 5, max: 10 + offset?: number; // for pagination + freshness?: "pd" | "pw" | "pm" | "py"; // past day/week/month/year +} + +/** + * Brave Search API response type. + */ +interface BraveSearchResponse { + web?: Array<{ + title: string; + url: string; + description: string; + thumbnail?: string; + }>; + error?: string; +} + +/** + * Creates a web search tool with access to the VS Code SecretStorage. + * + * The tool uses the Brave Search API to query the internet. The API key + * is read from SecretStorage (stored via the 'Champ: Set Brave API Key' command). + */ +export function createWebSearchTool(secretStorage: { + get: (key: string) => PromiseLike; +}): Tool { + return { + name: "web_search", + description: + "Search the internet for information using the Brave Search API. Returns ranked search results with titles, URLs, and snippets. Requires a Brave Search API key configured via 'Champ: Set Brave API Key' command.", + parameters: { + type: "object", + properties: { + query: { + type: "string", + description: + "The search query to execute (e.g., 'TypeScript generics')", + }, + count: { + type: "number", + description: "Number of results to return (default: 5, max: 10)", + }, + offset: { + type: "number", + description: "Pagination offset for result sets (default: 0)", + }, + freshness: { + type: "string", + enum: ["pd", "pw", "pm", "py"], + description: + "Filter by freshness: pd=past day, pw=past week, pm=past month, py=past year", + }, + }, + required: ["query"], + }, + requiresApproval: false, + + async execute( + args: Record, + _context: ToolExecutionContext, + ): Promise { + const query = args.query as string; + let count = (args.count as number) ?? DEFAULT_RESULTS; + const offset = (args.offset as number) ?? 0; + const freshness = args.freshness as string | undefined; + + // Validate query + if (!query || typeof query !== "string" || query.trim().length === 0) { + return { + success: false, + output: + "Error: query parameter is required and must be a non-empty string", + }; + } + + // Enforce max results limit + if (count < 1 || count > MAX_RESULTS) { + count = Math.min(Math.max(count, 1), MAX_RESULTS); + } + + // Get API key from SecretStorage + const apiKey = await secretStorage.get("brave-search-api-key"); + if (!apiKey) { + return { + success: false, + output: + "Error: Brave API key not configured. " + + "Please run the 'Champ: Set Brave API Key' command to configure it.", + }; + } + + try { + // Build API URL with parameters + const url = new URL(BRAVE_SEARCH_API_ENDPOINT); + url.searchParams.append("q", query); + url.searchParams.append("count", String(count)); + if (offset > 0) { + url.searchParams.append("offset", String(offset)); + } + if (freshness) { + url.searchParams.append("freshness", freshness); + } + + // Make the API request + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); + + const response = await fetch(url.toString(), { + signal: controller.signal, + headers: { + "Accept-Encoding": "gzip", + "X-Subscription-Token": apiKey, + }, + }); + clearTimeout(timer); + + // Handle HTTP errors + if (!response.ok) { + return { + success: false, + output: `Error: HTTP ${response.status} ${response.statusText} from Brave Search API`, + }; + } + + // Parse response + let data: BraveSearchResponse; + try { + data = (await response.json()) as BraveSearchResponse; + } catch (err) { + return { + success: false, + output: `Error: Failed to parse Brave Search API response: ${err instanceof Error ? err.message : String(err)}`, + }; + } + + // Check for API errors + if (data.error) { + return { + success: false, + output: `Error: Brave Search API returned error: ${data.error}`, + }; + } + + // Extract and format results + const results = data.web ?? []; + if (results.length === 0) { + return { + success: true, + output: `No results found for: "${query}"`, + }; + } + + // Format results for output + const formattedResults = results + .slice(0, count) + .map((result, index) => { + const lines = [ + `${index + 1}. ${result.title}`, + ` URL: ${result.url}`, + ` ${result.description}`, + ]; + return lines.join("\n"); + }); + + const output = [ + `Search results for: "${query}"`, + `Found ${results.length} result(s)${offset > 0 ? ` (starting from offset ${offset})` : ""}`, + "", + formattedResults.join("\n\n"), + ].join("\n"); + + return { success: true, output }; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (msg.includes("abort") || msg.includes("AbortError")) { + return { + success: false, + output: `Error: Search request timed out after ${TIMEOUT_MS / 1000}s`, + }; + } + return { + success: false, + output: `Error: Failed to search the web: ${msg}`, + }; + } + }, + }; +} + +/** + * Create a default web search tool (exported for convenient registration). + * Note: In production, this requires injecting the actual VS Code SecretStorage. + */ +export const createDefaultWebSearchTool = (secretStorage: { + get: (key: string) => PromiseLike; +}): Tool => createWebSearchTool(secretStorage); diff --git a/test/unit/extension/active-workspace-folder.test.ts b/test/unit/extension/active-workspace-folder.test.ts new file mode 100644 index 0000000..d71e40d --- /dev/null +++ b/test/unit/extension/active-workspace-folder.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Lightweight mock of the vscode module used in tests +const mockFolders: Array<{ uri: { fsPath: string } }> = []; +let mockActiveEditorUri: string | undefined; + +vi.mock("vscode", () => ({ + workspace: { + get workspaceFolders() { + return mockFolders.length ? mockFolders : undefined; + }, + getWorkspaceFolder(uri: { fsPath: string }) { + return ( + mockFolders.find((f) => uri.fsPath.startsWith(f.uri.fsPath)) ?? + undefined + ); + }, + }, + window: { + get activeTextEditor() { + return mockActiveEditorUri + ? { document: { uri: { fsPath: mockActiveEditorUri } } } + : undefined; + }, + }, +})); + +// Import after mock is set up +import { resolveActiveWorkspaceFolder } from "../../../src/extension-utils"; + +beforeEach(() => { + mockFolders.length = 0; + mockActiveEditorUri = undefined; +}); + +describe("resolveActiveWorkspaceFolder", () => { + it("returns undefined when no workspace folders are open", () => { + expect(resolveActiveWorkspaceFolder()).toBeUndefined(); + }); + + it("returns workspaceFolders[0] when no editor is active", () => { + mockFolders.push({ uri: { fsPath: "/projects/alpha" } }); + mockFolders.push({ uri: { fsPath: "/projects/beta" } }); + expect(resolveActiveWorkspaceFolder()).toBe("/projects/alpha"); + }); + + it("returns the folder that owns the active editor file", () => { + mockFolders.push({ uri: { fsPath: "/projects/alpha" } }); + mockFolders.push({ uri: { fsPath: "/projects/beta" } }); + mockActiveEditorUri = "/projects/beta/src/main.ts"; + expect(resolveActiveWorkspaceFolder()).toBe("/projects/beta"); + }); + + it("falls back to workspaceFolders[0] when active file is outside all folders", () => { + mockFolders.push({ uri: { fsPath: "/projects/alpha" } }); + mockActiveEditorUri = "/tmp/scratch.ts"; + expect(resolveActiveWorkspaceFolder()).toBe("/projects/alpha"); + }); +}); diff --git a/test/unit/tools/web-search.test.ts b/test/unit/tools/web-search.test.ts new file mode 100644 index 0000000..a64bd12 --- /dev/null +++ b/test/unit/tools/web-search.test.ts @@ -0,0 +1,305 @@ +/** + * TDD: Tests for web_search tool. + * Validates Brave Search API integration with error handling and result formatting. + */ +import { describe, it, expect, beforeEach, vi, afterEach } from "vitest"; +import { createWebSearchTool } from "@/tools/web-search"; +import type { ToolExecutionContext } from "@/tools/types"; + +describe("web_search tool", () => { + let context: ToolExecutionContext; + let mockSecretStorage: { + get: (key: string) => PromiseLike; + }; + let fetchSpy: ReturnType; + + beforeEach(() => { + vi.clearAllMocks(); + mockSecretStorage = { + get: vi.fn(), + }; + context = { + workspaceRoot: "/test-workspace", + abortSignal: new AbortController().signal, + reportProgress: vi.fn(), + requestApproval: vi.fn(), + }; + fetchSpy = vi.spyOn(global, "fetch" as any); + }); + + afterEach(() => { + fetchSpy.mockRestore(); + }); + + it("should have correct metadata", async () => { + const tool = createWebSearchTool(mockSecretStorage); + expect(tool.name).toBe("web_search"); + expect(tool.requiresApproval).toBe(false); + expect(tool.parameters.required).toContain("query"); + expect(tool.parameters.properties.count).toBeDefined(); + }); + + it("should return error when API key is not set", async () => { + (mockSecretStorage.get as any).mockResolvedValue(undefined); + const tool = createWebSearchTool(mockSecretStorage); + + const result = await tool.execute({ query: "test" }, context); + expect(result.success).toBe(false); + expect(result.output).toContain("Brave API key not configured"); + }); + + it("should successfully search with valid API key", async () => { + (mockSecretStorage.get as any).mockResolvedValue("test-api-key"); + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ + web: [ + { + title: "Test Result 1", + url: "https://example.com/1", + description: "This is a test result", + thumbnail: "https://example.com/thumb.jpg", + }, + { + title: "Test Result 2", + url: "https://example.com/2", + description: "Another test result", + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + const tool = createWebSearchTool(mockSecretStorage); + const result = await tool.execute({ query: "test query" }, context); + + expect(result.success).toBe(true); + expect(result.output).toContain("Test Result 1"); + expect(result.output).toContain("https://example.com/1"); + expect(result.output).toContain("This is a test result"); + }); + + it("should truncate results to specified count", async () => { + (mockSecretStorage.get as any).mockResolvedValue("test-api-key"); + const webResults = Array.from({ length: 10 }, (_, i) => ({ + title: `Result ${i + 1}`, + url: `https://example.com/${i + 1}`, + description: `Description ${i + 1}`, + })); + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ web: webResults }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const tool = createWebSearchTool(mockSecretStorage); + const result = await tool.execute({ query: "test", count: 3 }, context); + + expect(result.success).toBe(true); + // Should only include first 3 results + expect(result.output).toContain("Result 1"); + expect(result.output).toContain("Result 2"); + expect(result.output).toContain("Result 3"); + expect(result.output).not.toContain("Result 4"); + }); + + it("should default to 5 results when count is not specified", async () => { + (mockSecretStorage.get as any).mockResolvedValue("test-api-key"); + const webResults = Array.from({ length: 10 }, (_, i) => ({ + title: `Result ${i + 1}`, + url: `https://example.com/${i + 1}`, + description: `Description ${i + 1}`, + })); + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ web: webResults }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const tool = createWebSearchTool(mockSecretStorage); + const result = await tool.execute({ query: "test" }, context); + + expect(result.success).toBe(true); + // Should only include first 5 results (default) + expect(result.output).toContain("Result 1"); + expect(result.output).toContain("Result 5"); + expect(result.output).not.toContain("Result 6"); + }); + + it("should handle API error responses", async () => { + (mockSecretStorage.get as any).mockResolvedValue("test-api-key"); + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ error: "Invalid API key" }), { + status: 401, + headers: { "content-type": "application/json" }, + }), + ); + + const tool = createWebSearchTool(mockSecretStorage); + const result = await tool.execute({ query: "test" }, context); + + expect(result.success).toBe(false); + expect(result.output).toContain("HTTP 401"); + }); + + it("should handle network errors", async () => { + (mockSecretStorage.get as any).mockResolvedValue("test-api-key"); + fetchSpy.mockRejectedValue(new Error("Network timeout")); + + const tool = createWebSearchTool(mockSecretStorage); + const result = await tool.execute({ query: "test" }, context); + + expect(result.success).toBe(false); + expect(result.output).toContain("Error"); + }); + + it("should properly format search results with URL and snippet", async () => { + (mockSecretStorage.get as any).mockResolvedValue("test-api-key"); + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ + web: [ + { + title: "How to use TypeScript", + url: "https://www.typescriptlang.org/docs/", + description: + "Official TypeScript documentation with guides and examples", + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + const tool = createWebSearchTool(mockSecretStorage); + const result = await tool.execute({ query: "typescript" }, context); + + expect(result.success).toBe(true); + expect(result.output).toContain("How to use TypeScript"); + expect(result.output).toContain("https://www.typescriptlang.org/docs/"); + expect(result.output).toContain("Official TypeScript documentation"); + }); + + it("should support pagination with offset parameter", async () => { + (mockSecretStorage.get as any).mockResolvedValue("test-api-key"); + fetchSpy.mockResolvedValue( + new Response( + JSON.stringify({ + web: [ + { + title: "Result 6", + url: "https://example.com/6", + description: "Sixth result", + }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ), + ); + + const tool = createWebSearchTool(mockSecretStorage); + const result = await tool.execute( + { query: "test", offset: 5, count: 1 }, + context, + ); + + expect(result.success).toBe(true); + // Verify that the offset parameter was used in the API call + expect(fetchSpy).toHaveBeenCalled(); + const callUrl = (fetchSpy.mock.calls[0]?.[0] as string) || ""; + expect(callUrl).toContain("offset=5"); + }); + + it("should respect freshness parameter", async () => { + (mockSecretStorage.get as any).mockResolvedValue("test-api-key"); + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ web: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const tool = createWebSearchTool(mockSecretStorage); + await tool.execute({ query: "test", freshness: "pw" }, context); + + // Verify the freshness parameter was passed in the API call + expect(fetchSpy).toHaveBeenCalled(); + const callUrl = (fetchSpy.mock.calls[0]?.[0] as string) || ""; + expect(callUrl).toContain("freshness=pw"); + }); + + it("should use correct Brave Search API endpoint", async () => { + (mockSecretStorage.get as any).mockResolvedValue("test-api-key"); + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ web: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const tool = createWebSearchTool(mockSecretStorage); + await tool.execute({ query: "test" }, context); + + expect(fetchSpy).toHaveBeenCalled(); + const callUrl = (fetchSpy.mock.calls[0]?.[0] as string) || ""; + expect(callUrl).toContain("api.search.brave.com"); + expect(callUrl).toContain("/res/v1/web/search"); + }); + + it("should handle malformed API response", async () => { + (mockSecretStorage.get as any).mockResolvedValue("test-api-key"); + fetchSpy.mockResolvedValue( + new Response("invalid json", { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const tool = createWebSearchTool(mockSecretStorage); + const result = await tool.execute({ query: "test" }, context); + + expect(result.success).toBe(false); + expect(result.output).toContain("Error"); + }); + + it("should enforce max results limit of 10", async () => { + (mockSecretStorage.get as any).mockResolvedValue("test-api-key"); + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ web: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const tool = createWebSearchTool(mockSecretStorage); + await tool.execute({ query: "test", count: 15 }, context); + + // Verify the count was capped at 10 + expect(fetchSpy).toHaveBeenCalled(); + const callUrl = (fetchSpy.mock.calls[0]?.[0] as string) || ""; + expect(callUrl).toContain("count=10"); + }); + + it("should include Authorization header with API key", async () => { + (mockSecretStorage.get as any).mockResolvedValue("my-secret-key"); + fetchSpy.mockResolvedValue( + new Response(JSON.stringify({ web: [] }), { + status: 200, + headers: { "content-type": "application/json" }, + }), + ); + + const tool = createWebSearchTool(mockSecretStorage); + await tool.execute({ query: "test" }, context); + + expect(fetchSpy).toHaveBeenCalled(); + const callOptions = fetchSpy.mock.calls[0]?.[1]; + expect(callOptions?.headers?.["Accept-Encoding"]).toBe("gzip"); + expect(callOptions?.headers?.["X-Subscription-Token"]).toBe( + "my-secret-key", + ); + }); +});