import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import * as path from "node:path"; import { createSourceList } from "../lib/citations"; import { createPolicy, type ApproveFn } from "../lib/files"; import { TOOL_DEFINITIONS, WEB_TOOL_NAMES, executeTool, toolsFor, type ToolContext } 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 the four always-available tools to the model", () => { // The file tools are in the list too, but they are gated by a preference, so the // unconditional ones are what this pins. const names = TOOL_DEFINITIONS.map((tool) => tool.function.name); expect(names.slice(0, 4)).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 each result under the number the model will cite", 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("[1] 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("[1] 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("sources", () => { it("keeps numbering across the calls in one turn", async () => { // Two searches in a turn must not each start at 1, or a marker in the answer // would point at two different pages at once. const list = createSourceList(); const turn = { ...context, sources: list }; fetchMock.mockResolvedValueOnce( jsonResponse({ results: [ { title: "First", url: "https://first.example", content: "First body" }, { title: "Second", url: "https://second.example", content: "Second body" }, ], }), ); fetchMock.mockResolvedValueOnce( jsonResponse({ results: [{ title: "Third", url: "https://third.example", content: "Third" }] }), ); const first = await executeTool("web_search", { query: "one" }, turn); const second = await executeTool("web_search", { query: "two" }, turn); expect(first.content).toContain("[1] First"); expect(second.content).toContain("[3] Third"); expect(second.sources?.map((source) => source.index)).toEqual([3]); }); it("returns only what the call itself added", async () => { // The turn's whole list is the flattening of its records, so a record that // repeated the running total would store the same page once per later call. const list = createSourceList(); 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, sources: list }); expect(result.sources?.map((source) => source.index)).toEqual([1, 2]); }); it("gives a page the number it already has, however the turn reached it", async () => { const list = createSourceList(); const turn = { ...context, sources: list }; fetchMock.mockResolvedValueOnce( jsonResponse({ results: [{ title: "First", url: "https://first.example", content: "B" }] }), ); fetchMock.mockResolvedValueOnce(jsonResponse({ title: "First", content: "body", links: [] })); await executeTool("web_search", { query: "x" }, turn); const page = await executeTool("web_fetch", { url: "https://first.example" }, turn); expect(page.content).toContain("[1] First"); // Nothing new, so nothing for the turn to store a second time. expect(page.sources).toEqual([]); }); it("numbers from one when the caller keeps no list of its own", async () => { // The format must not depend on whether anyone is collecting: a test or a // future caller with no interest in citations sees the same numbering. fetchMock.mockResolvedValueOnce( jsonResponse({ results: [{ title: "First", url: "https://first.example", content: "B" }] }), ); const result = await executeTool("web_search", { query: "x" }, context); expect(result.content).toContain("[1] First"); }); it("prints no number for a result with no address to link to", async () => { fetchMock.mockResolvedValueOnce( jsonResponse({ results: [{ title: "Nowhere", url: " ", content: "Body" }] }), ); const result = await executeTool("web_search", { query: "x" }, context); expect(result.content).toContain("Nowhere"); expect(result.content).not.toContain("[0]"); expect(result.sources).toEqual([]); }); }); 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 when nothing is configured", 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("no Location is set in preferences"); expect(result.content).toContain("Ask which city they are in"); }); it("never claims a city derived from the timezone name", async () => { // `Asia/Manila` is one zone covering the whole Philippines, so reporting // "City: Manila" was wrong for anyone in Cebu and confidently so. const result = await executeTool("get_location", {}, context); expect(result.content).not.toMatch(/^City:/m); expect(result.content).toContain("The timezone area is the timezone's own name, not the user's city."); }); it("puts the configured place first and says to prefer it", async () => { const result = await executeTool( "get_location", {}, { ...context, place: "Cebu City, Philippines", }, ); expect(result.content).toContain("Place: Cebu City, Philippines"); expect(result.content).toContain("prefer it"); expect(result.content).not.toContain("no Location is set"); expect(result.content.indexOf("Place:")).toBeLessThan(result.content.indexOf("Timezone:")); }); it("still reports the timezone alongside a configured place, for local time questions", async () => { const result = await executeTool("get_location", {}, { ...context, place: "Cebu City" }); expect(result.content).toContain("Timezone:"); expect(result.content).toContain("UTC offset:"); }); }); describe("get_location with IP geolocation", () => { const resolved = { status: "ok" as const, place: { place: "Cebu City, Central Visayas, Philippines", city: "Cebu City", region: "Central Visayas", country: "Philippines", timezone: "Asia/Manila", source: "ipwho.is", fetchedAt: 1_000, }, }; it("reports the resolved place and says how approximate it is", async () => { const ipPlace = vi.fn(async () => resolved); const result = await executeTool("get_location", {}, { ...context, ipPlace }); expect(result.isError).toBe(false); expect(result.content).toContain("Place: Cebu City, Central Visayas, Philippines"); expect(result.content).toContain("rough guess"); expect(result.content).toContain("ipwho.is"); }); it("does not look the address up at all when the user has said where they are", async () => { // The preference is both more accurate and free, so sending the IP anyway // would be a privacy cost for nothing. const ipPlace = vi.fn(async () => resolved); const result = await executeTool( "get_location", {}, { ...context, place: "Cebu City, Philippines", ipPlace, }, ); expect(ipPlace).not.toHaveBeenCalled(); expect(result.content).toContain("Place: Cebu City, Philippines"); expect(result.content).not.toContain("ipwho.is"); }); it("surfaces a failed lookup instead of quietly serving the region answer", async () => { const ipPlace = vi.fn(async () => ({ status: "failed" as const, message: "ipwho.is answered HTTP 429" })); const result = await executeTool("get_location", {}, { ...context, ipPlace }); expect(result.content).toContain("IP geolocation is switched on but the lookup failed"); expect(result.content).toContain("HTTP 429"); // The region answer is still there, so the tool stays useful. expect(result.content).toContain("Timezone:"); }); it("does not blame an unset preference when the lookup itself failed", async () => { // These are different problems and send the user to different places. The // original wording said "no Location is set in preferences" for both, which // is exactly what made a broken lookup look like a missing setting. const ipPlace = vi.fn(async () => ({ status: "failed" as const, message: "ipwho.is answered HTTP 429" })); const result = await executeTool("get_location", {}, { ...context, ipPlace }); expect(result.content).not.toContain("no Location is set in preferences"); }); it("tells the model to pass a failed lookup on, since it is the only channel to the user", async () => { const ipPlace = vi.fn(async () => ({ status: "failed" as const, message: "offline" })); const result = await executeTool("get_location", {}, { ...context, ipPlace }); expect(result.content).toContain("Tell the user"); expect(result.content).toContain("Setting Location in preferences avoids the lookup"); }); it("flags an IP timezone that disagrees with the clock, which usually means a VPN", async () => { const ipPlace = vi.fn(async () => ({ status: "ok" as const, place: { ...resolved.place, timezone: "America/New_York" }, })); const result = await executeTool("get_location", {}, { ...context, ipPlace }); expect(result.content).toContain("differs from the device timezone"); }); it("says nothing about a mismatch when the timezones agree", async () => { const ipPlace = vi.fn(async () => resolved); const result = await executeTool("get_location", {}, { ...context, ipPlace }); // The test host's own zone varies, so this asserts the absence of the note // only for the case where the provider echoed the same value. if (result.content.includes("IP-reported timezone")) { expect(result.content).toContain("differs"); } else { expect(result.content).not.toContain("IP-reported timezone"); } }); }); 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(); }); }); describe("the file tools", () => { let home: string; beforeEach(async () => { // Resolved, because mkdtemp on macOS hands back a path under /var, which is a // symlink: the policy would then be built on one path and check another. home = await realpath(await mkdtemp(path.join(tmpdir(), "lam-tools-"))); await mkdir(path.join(home, "Downloads"), { recursive: true }); await writeFile(path.join(home, "Downloads", "notes.md"), "alpha\nbeta\n"); }); afterEach(async () => { await rm(home, { recursive: true, force: true }); }); const context = (approve: ApproveFn = async () => true): ToolContext => ({ host: "h", apiKey: "k", files: { policy: createPolicy({ home }), approve }, }); it("is absent from the list unless file access is on", () => { expect(toolsFor({ webEnabled: true }).some((tool) => tool.function.name === "read_file")).toBe(false); expect( toolsFor({ webEnabled: true, fileEnabled: true }).some((tool) => tool.function.name === "read_file"), ).toBe(true); }); it("leaves the web tools alone when file access is off", () => { const names = toolsFor({ webEnabled: true }).map((tool) => tool.function.name); expect(names).toContain("web_search"); expect(names).not.toContain("delete_path"); }); it("reads a file through the policy", async () => { const result = await executeTool( "read_file", { path: path.join(home, "Downloads", "notes.md") }, context(), ); expect(result.isError).toBe(false); expect(result.content).toContain("alpha"); }); it("writes through the approval, and reports a decline as an error", async () => { const written = await executeTool( "write_file", { path: path.join(home, "Downloads", "new.md"), content: "x\n" }, context(), ); expect(written.isError).toBe(false); const refused = await executeTool( "write_file", { path: path.join(home, "Downloads", "other.md"), content: "x\n" }, context(async () => false), ); expect(refused.isError).toBe(true); expect(refused.content).toContain("declined"); }); it("returns a policy refusal as an error the model can read", async () => { const result = await executeTool("read_file", { path: "/etc/passwd" }, context()); expect(result.isError).toBe(true); expect(result.content).toContain("outside the paths"); }); it("lists a folder", async () => { const result = await executeTool("list_directory", { path: home }, context()); expect(result.isError).toBe(false); expect(result.content).toContain("Downloads/"); }); it("searches for a file by name", async () => { const result = await executeTool("search_files", { path: home, pattern: "*.md" }, context()); expect(result.isError).toBe(false); expect(result.content).toContain("notes.md"); }); it("edits a file", async () => { const result = await executeTool( "edit_file", { path: path.join(home, "Downloads", "notes.md"), old_string: "alpha", new_string: "ALPHA", }, context(), ); expect(result.isError).toBe(false); expect(result.content).toContain("1 occurrence"); }); it("deletes and moves", async () => { const moved = await executeTool( "move_path", { from: path.join(home, "Downloads", "notes.md"), to: path.join(home, "Downloads", "moved.md"), }, context(), ); expect(moved.isError).toBe(false); const deleted = await executeTool( "delete_path", { path: path.join(home, "Downloads", "moved.md") }, context(), ); expect(deleted.isError).toBe(false); expect(deleted.content).toContain("Deleted"); }); it("says a tool is unavailable when the context carries no policy", async () => { const result = await executeTool("read_file", { path: "~" }, { host: "h", apiKey: "k" }); expect(result.isError).toBe(true); expect(result.content).toContain("not available"); }); it("requires its arguments", async () => { const read = await executeTool("read_file", {}, context()); expect(read.isError).toBe(true); expect(read.content).toContain("path"); const search = await executeTool("search_files", { path: home }, context()); expect(search.isError).toBe(true); expect(search.content).toContain("pattern"); }); it("labels every file tool for the activity line", () => { const tools = toolsFor({ webEnabled: true, fileEnabled: true }); for (const name of [ "list_directory", "search_files", "read_file", "write_file", "edit_file", "delete_path", "move_path", ]) { expect( tools.some((tool) => tool.function.name === name), name, ).toBe(true); } }); });