Files
lam-agent-vicinae/src/__tests__/tools.test.ts
T
adrianbonpin f42a828360 feat: seven file tools behind the policy and an approval
The file tools are absent from the list unless file access is on, which is the
difference between a model that cannot call them and one that is told not to. Each
handler is thin: argument checks here, and the operation itself in files.ts.

move_path reads `from`, not the shared path helper, which is the bug the tests
caught: an empty source path would have been refused by the policy on every move.
2026-10-07 17:00:08 +08:00

626 lines
23 KiB
TypeScript

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<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 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<string, string>).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);
}
});
});