test: cover the Ollama web tools and mark trimmed snippets
Both web tools were already implemented and wired, but the suite only exercised get_datetime, so neither had any direct coverage. Adds tools.test.ts pinning the contract for both: - web_search: request shape (endpoint, bearer token, body), max_results clamped into 1..10, empty result sets, and how results render - web_fetch: request shape, page content, title fallback, truncation - failure paths for both, including a rejected API key - the webEnabled filter, so the local tools survive and the web tools go Also marks a trimmed search snippet with "[truncated]" rather than silently dropping the tail, which is what web_fetch already did. Also replaces the extension icon with a white tile and a black bubble. Endpoint paths were checked against the live Ollama API: /api/chat, /api/web_search and /api/web_fetch all return 401 unauthenticated (so the route exists and demands a key), and /api/tags returns 405 to a POST, which also confirms the path. No authenticated call was made. Test count: 77 -> 96.
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 16 KiB |
@@ -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<string, unknown> {
|
||||
return JSON.parse(request(index).init.body as string) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, string>).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();
|
||||
});
|
||||
});
|
||||
+7
-1
@@ -208,7 +208,13 @@ async function runWebSearch(args: Record<string, unknown>, 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");
|
||||
|
||||
Reference in New Issue
Block a user