refactor(agent): dedupe harness and command workflows

This commit is contained in:
Peter Steinberger
2026-02-16 14:52:09 +00:00
parent 04892ee230
commit f717a13039
204 changed files with 7366 additions and 11540 deletions

View File

@@ -12,6 +12,28 @@ vi.mock("../agent-scope.js", () => ({
import { createCronTool } from "./cron-tool.js";
describe("cron tool", () => {
async function executeAddAndReadDelivery(params: {
callId: string;
agentSessionKey: string;
delivery?: { mode?: string; channel?: string; to?: string } | null;
}) {
const tool = createCronTool({ agentSessionKey: params.agentSessionKey });
await tool.execute(params.callId, {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
...(params.delivery !== undefined ? { delivery: params.delivery } : {}),
},
});
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: { delivery?: { mode?: string; channel?: string; to?: string } };
};
return call?.params?.delivery;
}
beforeEach(() => {
callGatewayMock.mockReset();
callGatewayMock.mockResolvedValue({ ok: true });
@@ -249,24 +271,12 @@ describe("cron tool", () => {
});
it("infers delivery from threaded session keys", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createCronTool({
agentSessionKey: "agent:main:slack:channel:general:thread:1699999999.0001",
});
await tool.execute("call-thread", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
},
});
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: { delivery?: { mode?: string; channel?: string; to?: string } };
};
expect(call?.params?.delivery).toEqual({
expect(
await executeAddAndReadDelivery({
callId: "call-thread",
agentSessionKey: "agent:main:slack:channel:general:thread:1699999999.0001",
}),
).toEqual({
mode: "announce",
channel: "slack",
to: "general",
@@ -274,24 +284,12 @@ describe("cron tool", () => {
});
it("preserves telegram forum topics when inferring delivery", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createCronTool({
agentSessionKey: "agent:main:telegram:group:-1001234567890:topic:99",
});
await tool.execute("call-telegram-topic", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
},
});
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: { delivery?: { mode?: string; channel?: string; to?: string } };
};
expect(call?.params?.delivery).toEqual({
expect(
await executeAddAndReadDelivery({
callId: "call-telegram-topic",
agentSessionKey: "agent:main:telegram:group:-1001234567890:topic:99",
}),
).toEqual({
mode: "announce",
channel: "telegram",
to: "-1001234567890:topic:99",
@@ -299,23 +297,13 @@ describe("cron tool", () => {
});
it("infers delivery when delivery is null", async () => {
callGatewayMock.mockResolvedValueOnce({ ok: true });
const tool = createCronTool({ agentSessionKey: "agent:main:dm:alice" });
await tool.execute("call-null-delivery", {
action: "add",
job: {
name: "reminder",
schedule: { at: new Date(123).toISOString() },
payload: { kind: "agentTurn", message: "hello" },
expect(
await executeAddAndReadDelivery({
callId: "call-null-delivery",
agentSessionKey: "agent:main:dm:alice",
delivery: null,
},
});
const call = callGatewayMock.mock.calls[0]?.[0] as {
params?: { delivery?: { mode?: string; channel?: string; to?: string } };
};
expect(call?.params?.delivery).toEqual({
}),
).toEqual({
mode: "announce",
to: "alice",
});

View File

@@ -62,6 +62,22 @@ function createMinimaxImageConfig(): OpenClawConfig {
};
}
async function expectImageToolExecOk(
tool: {
execute: (toolCallId: string, input: { prompt: string; image: string }) => Promise<unknown>;
},
image: string,
) {
await expect(
tool.execute("t1", {
prompt: "Describe the image.",
image,
}),
).resolves.toMatchObject({
content: [{ type: "text", text: "ok" }],
});
}
describe("image tool implicit imageModel config", () => {
const priorFetch = global.fetch;
@@ -220,14 +236,7 @@ describe("image tool implicit imageModel config", () => {
throw new Error("expected image tool");
}
await expect(
withWorkspace.execute("t1", {
prompt: "Describe the image.",
image: imagePath,
}),
).resolves.toMatchObject({
content: [{ type: "text", text: "ok" }],
});
await expectImageToolExecOk(withWorkspace, imagePath);
expect(fetch).toHaveBeenCalledTimes(1);
} finally {
@@ -250,14 +259,7 @@ describe("image tool implicit imageModel config", () => {
throw new Error("expected image tool");
}
await expect(
tool.execute("t1", {
prompt: "Describe the image.",
image: imagePath,
}),
).resolves.toMatchObject({
content: [{ type: "text", text: "ok" }],
});
await expectImageToolExecOk(tool, imagePath);
expect(fetch).toHaveBeenCalledTimes(1);
} finally {
@@ -383,15 +385,15 @@ describe("image tool MiniMax VLM routing", () => {
global.fetch = priorFetch;
});
it("calls /v1/coding_plan/vlm for minimax image models", async () => {
async function createMinimaxVlmFixture(baseResp: { status_code: number; status_msg: string }) {
const fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: new Headers(),
json: async () => ({
content: "ok",
base_resp: { status_code: 0, status_msg: "" },
content: baseResp.status_code === 0 ? "ok" : "",
base_resp: baseResp,
}),
});
// @ts-expect-error partial global
@@ -407,6 +409,11 @@ describe("image tool MiniMax VLM routing", () => {
if (!tool) {
throw new Error("expected image tool");
}
return { fetch, tool };
}
it("calls /v1/coding_plan/vlm for minimax image models", async () => {
const { fetch, tool } = await createMinimaxVlmFixture({ status_code: 0, status_msg: "" });
const res = await tool.execute("t1", {
prompt: "Describe the image.",
@@ -428,29 +435,7 @@ describe("image tool MiniMax VLM routing", () => {
});
it("surfaces MiniMax API errors from /v1/coding_plan/vlm", async () => {
const fetch = vi.fn().mockResolvedValue({
ok: true,
status: 200,
statusText: "OK",
headers: new Headers(),
json: async () => ({
content: "",
base_resp: { status_code: 1004, status_msg: "bad key" },
}),
});
// @ts-expect-error partial global
global.fetch = fetch;
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-minimax-vlm-"));
vi.stubEnv("MINIMAX_API_KEY", "minimax-test");
const cfg: OpenClawConfig = {
agents: { defaults: { model: { primary: "minimax/MiniMax-M2.1" } } },
};
const tool = createImageTool({ config: cfg, agentDir });
expect(tool).not.toBeNull();
if (!tool) {
throw new Error("expected image tool");
}
const { tool } = await createMinimaxVlmFixture({ status_code: 1004, status_msg: "bad key" });
await expect(
tool.execute("t1", {

View File

@@ -19,17 +19,22 @@ vi.mock("../../infra/outbound/message-action-runner.js", async () => {
};
});
function mockSendResult(overrides: { channel?: string; to?: string } = {}) {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: overrides.channel ?? "telegram",
...(overrides.to ? { to: overrides.to } : {}),
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
}
describe("message tool agent routing", () => {
it("derives agentId from the session key", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "telegram",
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
mockSendResult();
const tool = createMessageTool({
agentSessionKey: "agent:alpha:main",
@@ -50,16 +55,7 @@ describe("message tool agent routing", () => {
describe("message tool path passthrough", () => {
it("does not convert path to media for send", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "telegram",
to: "telegram:123",
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
mockSendResult({ to: "telegram:123" });
const tool = createMessageTool({
config: {} as never,
@@ -78,16 +74,7 @@ describe("message tool path passthrough", () => {
});
it("does not convert filePath to media for send", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "telegram",
to: "telegram:123",
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
mockSendResult({ to: "telegram:123" });
const tool = createMessageTool({
config: {} as never,
@@ -164,16 +151,7 @@ describe("message tool description", () => {
describe("message tool reasoning tag sanitization", () => {
it("strips <think> tags from text field before sending", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "signal",
to: "signal:+15551234567",
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
mockSendResult({ channel: "signal", to: "signal:+15551234567" });
const tool = createMessageTool({ config: {} as never });
@@ -188,16 +166,7 @@ describe("message tool reasoning tag sanitization", () => {
});
it("strips <think> tags from content field before sending", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "discord",
to: "discord:123",
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
mockSendResult({ channel: "discord", to: "discord:123" });
const tool = createMessageTool({ config: {} as never });
@@ -212,16 +181,7 @@ describe("message tool reasoning tag sanitization", () => {
});
it("passes through text without reasoning tags unchanged", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "signal",
to: "signal:+15551234567",
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
mockSendResult({ channel: "signal", to: "signal:+15551234567" });
const tool = createMessageTool({ config: {} as never });
@@ -238,16 +198,7 @@ describe("message tool reasoning tag sanitization", () => {
describe("message tool sandbox passthrough", () => {
it("forwards sandboxRoot to runMessageAction", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "telegram",
to: "telegram:123",
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
mockSendResult({ to: "telegram:123" });
const tool = createMessageTool({
config: {} as never,
@@ -265,16 +216,7 @@ describe("message tool sandbox passthrough", () => {
});
it("omits sandboxRoot when not configured", async () => {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({
kind: "send",
action: "send",
channel: "telegram",
to: "telegram:123",
handledBy: "plugin",
payload: {},
dryRun: true,
} satisfies MessageActionRunResult);
mockSendResult({ to: "telegram:123" });
const tool = createMessageTool({
config: {} as never,

View File

@@ -22,6 +22,29 @@ vi.mock("../../telegram/send.js", () => ({
}));
describe("handleTelegramAction", () => {
const defaultReactionAction = {
action: "react",
chatId: "123",
messageId: "456",
emoji: "✅",
} as const;
function reactionConfig(reactionLevel: "minimal" | "extensive" | "off" | "ack"): OpenClawConfig {
return {
channels: { telegram: { botToken: "tok", reactionLevel } },
} as OpenClawConfig;
}
async function expectReactionAdded(reactionLevel: "minimal" | "extensive") {
await handleTelegramAction(defaultReactionAction, reactionConfig(reactionLevel));
expect(reactMessageTelegram).toHaveBeenCalledWith(
"123",
456,
"✅",
expect.objectContaining({ token: "tok", remove: false }),
);
}
beforeEach(() => {
reactMessageTelegram.mockClear();
sendMessageTelegram.mockClear();
@@ -39,24 +62,7 @@ describe("handleTelegramAction", () => {
});
it("adds reactions when reactionLevel is minimal", async () => {
const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } },
} as OpenClawConfig;
await handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: "456",
emoji: "✅",
},
cfg,
);
expect(reactMessageTelegram).toHaveBeenCalledWith(
"123",
456,
"✅",
expect.objectContaining({ token: "tok", remove: false }),
);
await expectReactionAdded("minimal");
});
it("surfaces non-fatal reaction warnings", async () => {
@@ -64,18 +70,7 @@ describe("handleTelegramAction", () => {
ok: false,
warning: "Reaction unavailable: ✅",
});
const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "minimal" } },
} as OpenClawConfig;
const result = await handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: "456",
emoji: "✅",
},
cfg,
);
const result = await handleTelegramAction(defaultReactionAction, reactionConfig("minimal"));
const textPayload = result.content.find((item) => item.type === "text");
expect(textPayload?.type).toBe("text");
const parsed = JSON.parse((textPayload as { type: "text"; text: string }).text) as {
@@ -91,24 +86,7 @@ describe("handleTelegramAction", () => {
});
it("adds reactions when reactionLevel is extensive", async () => {
const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } },
} as OpenClawConfig;
await handleTelegramAction(
{
action: "react",
chatId: "123",
messageId: "456",
emoji: "✅",
},
cfg,
);
expect(reactMessageTelegram).toHaveBeenCalledWith(
"123",
456,
"✅",
expect.objectContaining({ token: "tok", remove: false }),
);
await expectReactionAdded("extensive");
});
it("removes reactions on empty emoji", async () => {
@@ -167,9 +145,7 @@ describe("handleTelegramAction", () => {
});
it("removes reactions when remove flag set", async () => {
const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "extensive" } },
} as OpenClawConfig;
const cfg = reactionConfig("extensive");
await handleTelegramAction(
{
action: "react",
@@ -189,9 +165,7 @@ describe("handleTelegramAction", () => {
});
it("blocks reactions when reactionLevel is off", async () => {
const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "off" } },
} as OpenClawConfig;
const cfg = reactionConfig("off");
await expect(
handleTelegramAction(
{
@@ -206,9 +180,7 @@ describe("handleTelegramAction", () => {
});
it("blocks reactions when reactionLevel is ack", async () => {
const cfg = {
channels: { telegram: { botToken: "tok", reactionLevel: "ack" } },
} as OpenClawConfig;
const cfg = reactionConfig("ack");
await expect(
handleTelegramAction(
{

View File

@@ -1,28 +1,14 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as ssrf from "../../infra/net/ssrf.js";
import { describe, expect, it, vi } from "vitest";
import * as logger from "../../logger.js";
import {
createBaseWebFetchToolConfig,
installWebFetchSsrfHarness,
} from "./web-fetch.test-harness.js";
import "./web-fetch.test-mocks.js";
import { createWebFetchTool } from "./web-tools.js";
// Avoid dynamic-importing heavy readability deps in this unit test suite.
vi.mock("./web-fetch-utils.js", async () => {
const actual =
await vi.importActual<typeof import("./web-fetch-utils.js")>("./web-fetch-utils.js");
return {
...actual,
extractReadableContent: vi.fn().mockResolvedValue({
title: "HTML Page",
text: "HTML Page\n\nContent here.",
}),
};
});
const lookupMock = vi.fn();
const resolvePinnedHostname = ssrf.resolvePinnedHostname;
const baseToolConfig = {
config: {
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
},
} as const;
const baseToolConfig = createBaseWebFetchToolConfig();
installWebFetchSsrfHarness();
function makeHeaders(map: Record<string, string>): { get: (key: string) => string | null } {
return {
@@ -49,22 +35,6 @@ function htmlResponse(body: string): Response {
}
describe("web_fetch Cloudflare Markdown for Agents", () => {
const priorFetch = global.fetch;
beforeEach(() => {
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation((hostname) =>
resolvePinnedHostname(hostname, lookupMock),
);
});
afterEach(() => {
// @ts-expect-error restore
global.fetch = priorFetch;
lookupMock.mockReset();
vi.restoreAllMocks();
});
it("sends Accept header preferring text/markdown", async () => {
const fetchSpy = vi.fn().mockResolvedValue(markdownResponse("# Test Page\n\nHello world."));
// @ts-expect-error mock fetch

View File

@@ -1,47 +1,15 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import * as ssrf from "../../infra/net/ssrf.js";
import { describe, expect, it, vi } from "vitest";
import {
createBaseWebFetchToolConfig,
installWebFetchSsrfHarness,
} from "./web-fetch.test-harness.js";
import "./web-fetch.test-mocks.js";
import { createWebFetchTool } from "./web-tools.js";
// Avoid dynamic-importing heavy readability deps in this unit test suite.
vi.mock("./web-fetch-utils.js", async () => {
const actual =
await vi.importActual<typeof import("./web-fetch-utils.js")>("./web-fetch-utils.js");
return {
...actual,
extractReadableContent: vi.fn().mockResolvedValue({
title: "HTML Page",
text: "HTML Page\n\nContent here.",
}),
};
});
const lookupMock = vi.fn();
const resolvePinnedHostname = ssrf.resolvePinnedHostname;
const baseToolConfig = {
config: {
tools: {
web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false }, maxResponseBytes: 1024 } },
},
},
} as const;
const baseToolConfig = createBaseWebFetchToolConfig({ maxResponseBytes: 1024 });
installWebFetchSsrfHarness();
describe("web_fetch response size limits", () => {
const priorFetch = global.fetch;
beforeEach(() => {
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation((hostname) =>
resolvePinnedHostname(hostname, lookupMock),
);
});
afterEach(() => {
// @ts-expect-error restore
global.fetch = priorFetch;
lookupMock.mockReset();
vi.restoreAllMocks();
});
it("caps response bytes and does not hang on endless streams", async () => {
const chunk = new TextEncoder().encode("<html><body><div>hi</div></body></html>");
const stream = new ReadableStream<Uint8Array>({

View File

@@ -28,6 +28,30 @@ function textResponse(body: string): Response {
} as Response;
}
function setMockFetch(impl?: (...args: unknown[]) => unknown) {
const fetchSpy = vi.fn(impl);
global.fetch = fetchSpy as typeof fetch;
return fetchSpy;
}
async function createWebFetchToolForTest(params?: {
firecrawl?: { enabled?: boolean; apiKey?: string };
}) {
const { createWebFetchTool } = await import("./web-tools.js");
return createWebFetchTool({
config: {
tools: {
web: {
fetch: {
cacheTtlMinutes: 0,
firecrawl: params?.firecrawl ?? { enabled: false },
},
},
},
},
});
}
describe("web_fetch SSRF protection", () => {
const priorFetch = global.fetch;
@@ -45,22 +69,9 @@ describe("web_fetch SSRF protection", () => {
});
it("blocks localhost hostnames before fetch/firecrawl", async () => {
const fetchSpy = vi.fn();
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: {
web: {
fetch: {
cacheTtlMinutes: 0,
firecrawl: { apiKey: "firecrawl-test" },
},
},
},
},
const fetchSpy = setMockFetch();
const tool = await createWebFetchToolForTest({
firecrawl: { apiKey: "firecrawl-test" },
});
await expect(tool?.execute?.("call", { url: "http://localhost/test" })).rejects.toThrow(
@@ -71,16 +82,8 @@ describe("web_fetch SSRF protection", () => {
});
it("blocks private IP literals without DNS", async () => {
const fetchSpy = vi.fn();
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
},
});
const fetchSpy = setMockFetch();
const tool = await createWebFetchToolForTest();
await expect(tool?.execute?.("call", { url: "http://127.0.0.1/test" })).rejects.toThrow(
/private|internal|blocked/i,
@@ -100,16 +103,8 @@ describe("web_fetch SSRF protection", () => {
return [{ address: "10.0.0.5", family: 4 }];
});
const fetchSpy = vi.fn();
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
},
});
const fetchSpy = setMockFetch();
const tool = await createWebFetchToolForTest();
await expect(tool?.execute?.("call", { url: "https://private.test/resource" })).rejects.toThrow(
/private|internal|blocked/i,
@@ -120,19 +115,11 @@ describe("web_fetch SSRF protection", () => {
it("blocks redirects to private hosts", async () => {
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
const fetchSpy = vi.fn().mockResolvedValueOnce(redirectResponse("http://127.0.0.1/secret"));
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: {
web: {
fetch: { cacheTtlMinutes: 0, firecrawl: { apiKey: "firecrawl-test" } },
},
},
},
const fetchSpy = setMockFetch().mockResolvedValueOnce(
redirectResponse("http://127.0.0.1/secret"),
);
const tool = await createWebFetchToolForTest({
firecrawl: { apiKey: "firecrawl-test" },
});
await expect(tool?.execute?.("call", { url: "https://example.com" })).rejects.toThrow(
@@ -144,16 +131,8 @@ describe("web_fetch SSRF protection", () => {
it("allows public hosts", async () => {
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
const fetchSpy = vi.fn().mockResolvedValue(textResponse("ok"));
// @ts-expect-error mock fetch
global.fetch = fetchSpy;
const { createWebFetchTool } = await import("./web-tools.js");
const tool = createWebFetchTool({
config: {
tools: { web: { fetch: { cacheTtlMinutes: 0, firecrawl: { enabled: false } } } },
},
});
setMockFetch().mockResolvedValue(textResponse("ok"));
const tool = await createWebFetchToolForTest();
const result = await tool?.execute?.("call", { url: "https://example.com" });
expect(result?.details).toMatchObject({

View File

@@ -0,0 +1,49 @@
import { afterEach, beforeEach, vi } from "vitest";
import * as ssrf from "../../infra/net/ssrf.js";
export function installWebFetchSsrfHarness() {
const lookupMock = vi.fn();
const resolvePinnedHostname = ssrf.resolvePinnedHostname;
const priorFetch = global.fetch;
beforeEach(() => {
lookupMock.mockResolvedValue([{ address: "93.184.216.34", family: 4 }]);
vi.spyOn(ssrf, "resolvePinnedHostname").mockImplementation((hostname) =>
resolvePinnedHostname(hostname, lookupMock),
);
});
afterEach(() => {
global.fetch = priorFetch;
lookupMock.mockReset();
vi.restoreAllMocks();
});
}
export function createBaseWebFetchToolConfig(opts?: { maxResponseBytes?: number }): {
config: {
tools: {
web: {
fetch: {
cacheTtlMinutes: number;
firecrawl: { enabled: boolean };
maxResponseBytes?: number;
};
};
};
};
} {
return {
config: {
tools: {
web: {
fetch: {
cacheTtlMinutes: 0,
firecrawl: { enabled: false },
...(opts?.maxResponseBytes ? { maxResponseBytes: opts.maxResponseBytes } : {}),
},
},
},
},
};
}

View File

@@ -0,0 +1,14 @@
import { vi } from "vitest";
// Avoid dynamic-importing heavy readability deps in unit test suites.
vi.mock("./web-fetch-utils.js", async () => {
const actual =
await vi.importActual<typeof import("./web-fetch-utils.js")>("./web-fetch-utils.js");
return {
...actual,
extractReadableContent: vi.fn().mockResolvedValue({
title: "HTML Page",
text: "HTML Page\n\nContent here.",
}),
};
});

View File

@@ -1,6 +1,34 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createWebFetchTool, createWebSearchTool } from "./web-tools.js";
function installMockFetch(payload: unknown) {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(payload),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
return mockFetch;
}
function createPerplexitySearchTool(perplexityConfig?: { apiKey?: string; baseUrl?: string }) {
return createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "perplexity",
...(perplexityConfig ? { perplexity: perplexityConfig } : {}),
},
},
},
},
sandboxed: true,
});
}
describe("web tools defaults", () => {
it("enables web_fetch by default (non-sandbox)", () => {
const tool = createWebFetchTool({ config: {}, sandboxed: false });
@@ -35,15 +63,7 @@ describe("web_search country and language parameters", () => {
});
it("should pass country parameter to Brave API", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ web: { results: [] } }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const mockFetch = installMockFetch({ web: { results: [] } });
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
expect(tool).not.toBeNull();
@@ -55,15 +75,7 @@ describe("web_search country and language parameters", () => {
});
it("should pass search_lang parameter to Brave API", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ web: { results: [] } }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const mockFetch = installMockFetch({ web: { results: [] } });
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
await tool?.execute?.(1, { query: "test", search_lang: "de" });
@@ -72,15 +84,7 @@ describe("web_search country and language parameters", () => {
});
it("should pass ui_lang parameter to Brave API", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ web: { results: [] } }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const mockFetch = installMockFetch({ web: { results: [] } });
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
await tool?.execute?.(1, { query: "test", ui_lang: "de" });
@@ -89,15 +93,7 @@ describe("web_search country and language parameters", () => {
});
it("should pass freshness parameter to Brave API", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ web: { results: [] } }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const mockFetch = installMockFetch({ web: { results: [] } });
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
await tool?.execute?.(1, { query: "test", freshness: "pw" });
@@ -106,15 +102,7 @@ describe("web_search country and language parameters", () => {
});
it("rejects invalid freshness values", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ web: { results: [] } }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const mockFetch = installMockFetch({ web: { results: [] } });
const tool = createWebSearchTool({ config: undefined, sandboxed: true });
const result = await tool?.execute?.(1, { query: "test", freshness: "yesterday" });
@@ -134,19 +122,11 @@ describe("web_search perplexity baseUrl defaults", () => {
it("defaults to Perplexity direct when PERPLEXITY_API_KEY is set", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: { tools: { web: { search: { provider: "perplexity" } } } },
sandboxed: true,
const mockFetch = installMockFetch({
choices: [{ message: { content: "ok" } }],
citations: [],
});
const tool = createPerplexitySearchTool();
await tool?.execute?.(1, { query: "test-openrouter" });
expect(mockFetch).toHaveBeenCalled();
@@ -161,19 +141,11 @@ describe("web_search perplexity baseUrl defaults", () => {
it("passes freshness to Perplexity provider as search_recency_filter", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: { tools: { web: { search: { provider: "perplexity" } } } },
sandboxed: true,
const mockFetch = installMockFetch({
choices: [{ message: { content: "ok" } }],
citations: [],
});
const tool = createPerplexitySearchTool();
await tool?.execute?.(1, { query: "perplexity-freshness-test", freshness: "pw" });
expect(mockFetch).toHaveBeenCalledOnce();
@@ -184,19 +156,11 @@ describe("web_search perplexity baseUrl defaults", () => {
it("defaults to OpenRouter when OPENROUTER_API_KEY is set", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", "");
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-test");
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: { tools: { web: { search: { provider: "perplexity" } } } },
sandboxed: true,
const mockFetch = installMockFetch({
choices: [{ message: { content: "ok" } }],
citations: [],
});
const tool = createPerplexitySearchTool();
await tool?.execute?.(1, { query: "test-openrouter-env" });
expect(mockFetch).toHaveBeenCalled();
@@ -212,19 +176,11 @@ describe("web_search perplexity baseUrl defaults", () => {
it("prefers PERPLEXITY_API_KEY when both env keys are set", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
vi.stubEnv("OPENROUTER_API_KEY", "sk-or-test");
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: { tools: { web: { search: { provider: "perplexity" } } } },
sandboxed: true,
const mockFetch = installMockFetch({
choices: [{ message: { content: "ok" } }],
citations: [],
});
const tool = createPerplexitySearchTool();
await tool?.execute?.(1, { query: "test-both-env" });
expect(mockFetch).toHaveBeenCalled();
@@ -233,28 +189,11 @@ describe("web_search perplexity baseUrl defaults", () => {
it("uses configured baseUrl even when PERPLEXITY_API_KEY is set", async () => {
vi.stubEnv("PERPLEXITY_API_KEY", "pplx-test");
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "perplexity",
perplexity: { baseUrl: "https://example.com/pplx" },
},
},
},
},
sandboxed: true,
const mockFetch = installMockFetch({
choices: [{ message: { content: "ok" } }],
citations: [],
});
const tool = createPerplexitySearchTool({ baseUrl: "https://example.com/pplx" });
await tool?.execute?.(1, { query: "test-config-baseurl" });
expect(mockFetch).toHaveBeenCalled();
@@ -262,28 +201,11 @@ describe("web_search perplexity baseUrl defaults", () => {
});
it("defaults to Perplexity direct when apiKey looks like Perplexity", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "perplexity",
perplexity: { apiKey: "pplx-config" },
},
},
},
},
sandboxed: true,
const mockFetch = installMockFetch({
choices: [{ message: { content: "ok" } }],
citations: [],
});
const tool = createPerplexitySearchTool({ apiKey: "pplx-config" });
await tool?.execute?.(1, { query: "test-config-apikey" });
expect(mockFetch).toHaveBeenCalled();
@@ -291,28 +213,11 @@ describe("web_search perplexity baseUrl defaults", () => {
});
it("defaults to OpenRouter when apiKey looks like OpenRouter", async () => {
const mockFetch = vi.fn(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve({ choices: [{ message: { content: "ok" } }], citations: [] }),
} as Response),
);
// @ts-expect-error mock fetch
global.fetch = mockFetch;
const tool = createWebSearchTool({
config: {
tools: {
web: {
search: {
provider: "perplexity",
perplexity: { apiKey: "sk-or-v1-test" },
},
},
},
},
sandboxed: true,
const mockFetch = installMockFetch({
choices: [{ message: { content: "ok" } }],
citations: [],
});
const tool = createPerplexitySearchTool({ apiKey: "sk-or-v1-test" });
await tool?.execute?.(1, { query: "test-openrouter-config" });
expect(mockFetch).toHaveBeenCalled();