diff --git a/assets/extension-icon.png b/assets/extension-icon.png index 78c06aa..2ff9065 100644 Binary files a/assets/extension-icon.png and b/assets/extension-icon.png differ diff --git a/src/__tests__/tools.test.ts b/src/__tests__/tools.test.ts new file mode 100644 index 0000000..7265b4c --- /dev/null +++ b/src/__tests__/tools.test.ts @@ -0,0 +1,246 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { TOOL_DEFINITIONS, WEB_TOOL_NAMES, executeTool, toolsFor } from "../lib/tools"; + +/** + * Direct coverage for the tool layer. + * + * `agent.test.ts` exercises the loop end to end but only with `get_datetime`. + * These tests pin the contract of the two Ollama web tools specifically: the + * request they put on the wire, how results are rendered for the model, and how + * failures are reported. + */ + +const fetchMock = vi.fn(); +const context = { host: "https://ollama.com", apiKey: "test-key" }; + +function jsonResponse(payload: unknown, status = 200): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function request(index = 0): { url: string; init: RequestInit } { + const call = fetchMock.mock.calls[index] as [string, RequestInit]; + return { url: call[0], init: call[1] }; +} + +function requestBody(index = 0): Record { + return JSON.parse(request(index).init.body as string) as Record; +} + +beforeEach(() => { + fetchMock.mockReset(); + vi.stubGlobal("fetch", fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("tool registration", () => { + it("exposes all four tools to the model", () => { + const names = TOOL_DEFINITIONS.map((tool) => tool.function.name); + + expect(names).toEqual(["get_datetime", "get_location", "web_search", "web_fetch"]); + }); + + it("marks both web tools as cloud-dependent", () => { + expect(WEB_TOOL_NAMES).toEqual(new Set(["web_search", "web_fetch"])); + }); + + it("offers the web tools only when the user has them enabled", () => { + const enabled = toolsFor({ webEnabled: true }).map((tool) => tool.function.name); + const disabled = toolsFor({ webEnabled: false }).map((tool) => tool.function.name); + + expect(enabled).toContain("web_search"); + expect(enabled).toContain("web_fetch"); + + expect(disabled).not.toContain("web_search"); + expect(disabled).not.toContain("web_fetch"); + // Local tools must survive the filter. + expect(disabled).toEqual(["get_datetime", "get_location"]); + }); + + it("gives every tool a description the model can act on", () => { + for (const tool of TOOL_DEFINITIONS) { + expect(tool.type).toBe("function"); + expect(tool.function.description.length).toBeGreaterThan(20); + expect(tool.function.parameters).toMatchObject({ type: "object" }); + } + }); +}); + +describe("web_search", () => { + it("posts the query to Ollama's web search endpoint", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ results: [{ title: "Vicinae", url: "https://vicinae.com", content: "A launcher." }] }), + ); + + const result = await executeTool("web_search", { query: "vicinae launcher", max_results: 3 }, context); + + expect(result.isError).toBe(false); + expect(request().url).toBe("https://ollama.com/api/web_search"); + expect(request().init.method).toBe("POST"); + expect((request().init.headers as Record).Authorization).toBe("Bearer test-key"); + expect(requestBody()).toEqual({ query: "vicinae launcher", max_results: 3 }); + }); + + it("renders results as numbered title, url, and snippet", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + results: [ + { title: "First", url: "https://first.example", content: "First body" }, + { title: "Second", url: "https://second.example", content: "Second body" }, + ], + }), + ); + + const result = await executeTool("web_search", { query: "x" }, context); + + expect(result.content).toContain('Web results for "x"'); + expect(result.content).toContain("1. First"); + expect(result.content).toContain("https://first.example"); + expect(result.content).toContain("2. Second"); + }); + + it("omits max_results when the model does not ask for it", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [] })); + + await executeTool("web_search", { query: "x" }, context); + + expect(requestBody()).toEqual({ query: "x" }); + }); + + it("clamps max_results into the range the API accepts", async () => { + fetchMock.mockResolvedValue(jsonResponse({ results: [] })); + + await executeTool("web_search", { query: "x", max_results: 99 }, context); + expect(requestBody(0).max_results).toBe(10); + + await executeTool("web_search", { query: "x", max_results: 0 }, context); + expect(requestBody(1).max_results).toBe(1); + }); + + it("reports an empty result set without treating it as a failure", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ results: [] })); + + const result = await executeTool("web_search", { query: "obscure" }, context); + + expect(result.isError).toBe(false); + expect(result.content).toContain("No web results"); + }); + + it("marks a snippet that had to be trimmed", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ results: [{ title: "Long", url: "https://long.example", content: "x".repeat(5000) }] }), + ); + + const result = await executeTool("web_search", { query: "x" }, context); + + expect(result.content).toContain("[truncated]"); + expect(result.content.length).toBeLessThan(2000); + }); + + it("rejects a call with no query without touching the network", async () => { + const result = await executeTool("web_search", {}, context); + + expect(result.isError).toBe(true); + expect(result.content).toContain("query"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("turns a rejected key into an actionable message", async () => { + fetchMock.mockResolvedValueOnce(new Response("unauthorized", { status: 401 })); + + const result = await executeTool("web_search", { query: "x" }, context); + + expect(result.isError).toBe(true); + expect(result.content).toContain("API key"); + expect(result.content).toContain("Ollama Cloud"); + }); +}); + +describe("web_fetch", () => { + it("posts the url and returns the page content", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ + title: "Example", + content: "# Example\n\nBody text.", + links: ["https://a.example", "https://b.example"], + }), + ); + + const result = await executeTool("web_fetch", { url: "https://example.com" }, context); + + expect(result.isError).toBe(false); + expect(request().url).toBe("https://ollama.com/api/web_fetch"); + expect(requestBody()).toEqual({ url: "https://example.com" }); + + expect(result.content).toContain("Title: Example"); + expect(result.content).toContain("Body text."); + expect(result.content).toContain("https://a.example"); + }); + + it("truncates an enormous page and says so", async () => { + fetchMock.mockResolvedValueOnce( + jsonResponse({ title: "Huge", content: "y".repeat(20_000), links: [] }), + ); + + const result = await executeTool("web_fetch", { url: "https://huge.example" }, context); + + expect(result.content).toContain("[content truncated]"); + expect(result.content.length).toBeLessThan(9000); + }); + + it("falls back to the url when the page reports no title", async () => { + fetchMock.mockResolvedValueOnce(jsonResponse({ content: "body", links: [] })); + + const result = await executeTool("web_fetch", { url: "https://untitled.example" }, context); + + expect(result.content).toContain("Title: https://untitled.example"); + }); + + it("rejects a call with no url without touching the network", async () => { + const result = await executeTool("web_fetch", {}, context); + + expect(result.isError).toBe(true); + expect(result.content).toContain("url"); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + +describe("local tools", () => { + it("uses the injected clock instead of the wall clock", async () => { + const result = await executeTool("get_datetime", {}, { + ...context, + now: new Date("2026-01-02T03:04:05Z"), + }); + + expect(result.isError).toBe(false); + expect(result.content).toContain("2026-01-02T03:04:05.000Z"); + expect(result.content).toContain("Unix timestamp: 1767323045"); + expect(result.content).toContain("Timezone:"); + // Deterministic across machines, unlike the rendered local date. + expect(result.content).toContain("Current date and time:"); + }); + + it("describes location as a region rather than an address", async () => { + const result = await executeTool("get_location", {}, context); + + expect(result.isError).toBe(false); + expect(result.content).toContain("Timezone:"); + expect(result.content).toContain("Locale:"); + expect(result.content).toContain("not a precise address"); + }); +}); + +describe("dispatch", () => { + it("reports an unknown tool instead of throwing", async () => { + const result = await executeTool("delete_everything", {}, context); + + expect(result.isError).toBe(true); + expect(result.content).toContain("Unknown tool"); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/tools.ts b/src/lib/tools.ts index a79b6c4..5a00bdd 100644 --- a/src/lib/tools.ts +++ b/src/lib/tools.ts @@ -208,7 +208,13 @@ async function runWebSearch(args: Record, context: ToolContext) const rendered = results .map((result, index) => { - const snippet = result.content.replace(/\s+/g, " ").trim().slice(0, MAX_SNIPPET_CHARS); + const collapsed = result.content.replace(/\s+/g, " ").trim(); + // Mark the cut rather than silently dropping the tail: the model should + // know a snippet was trimmed, and this matches what web_fetch does. + const snippet = + collapsed.length > MAX_SNIPPET_CHARS + ? `${collapsed.slice(0, MAX_SNIPPET_CHARS)} …[truncated]` + : collapsed; return `${index + 1}. ${result.title}\n ${result.url}\n ${snippet}`; }) .join("\n\n");