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.
626 lines
23 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|