test: dedupe pi tools schema coverage

This commit is contained in:
Peter Steinberger
2026-04-18 19:50:43 +01:00
parent 6ccac3d208
commit fe0055a1d1
8 changed files with 654 additions and 683 deletions

View File

@@ -1,152 +0,0 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import "./test-helpers/fast-bash-tools.js";
import "./test-helpers/fast-coding-tools.js";
import "./test-helpers/fast-openclaw-tools.js";
import { createOpenClawCodingTools } from "./pi-tools.js";
describe("createOpenClawCodingTools", () => {
const testConfig: OpenClawConfig = {};
it("preserves action enums in normalized schemas", () => {
const defaultTools = createOpenClawCodingTools({ config: testConfig, senderIsOwner: true });
const toolNames = ["canvas", "nodes", "cron", "gateway", "message"];
const missingNames = toolNames.filter(
(name) => !defaultTools.some((candidate) => candidate.name === name),
);
expect(missingNames).toEqual([]);
const collectActionValues = (schema: unknown, values: Set<string>): void => {
if (!schema || typeof schema !== "object") {
return;
}
const record = schema as Record<string, unknown>;
if (typeof record.const === "string") {
values.add(record.const);
}
if (Array.isArray(record.enum)) {
for (const value of record.enum) {
if (typeof value === "string") {
values.add(value);
}
}
}
if (Array.isArray(record.anyOf)) {
for (const variant of record.anyOf) {
collectActionValues(variant, values);
}
}
};
for (const name of toolNames) {
const tool = defaultTools.find((candidate) => candidate.name === name);
const parameters = tool?.parameters as {
properties?: Record<string, unknown>;
};
const action = parameters.properties?.action as
| { const?: unknown; enum?: unknown[] }
| undefined;
const values = new Set<string>();
collectActionValues(action, values);
const min =
name === "gateway"
? 1
: // Most tools expose multiple actions; keep this signal so schemas stay useful to models.
2;
expect(values.size).toBeGreaterThanOrEqual(min);
}
});
it("enforces apply_patch availability and canonical names across model/provider constraints", () => {
const defaultTools = createOpenClawCodingTools({ config: testConfig, senderIsOwner: true });
expect(defaultTools.some((tool) => tool.name === "exec")).toBe(true);
expect(defaultTools.some((tool) => tool.name === "process")).toBe(true);
expect(defaultTools.some((tool) => tool.name === "apply_patch")).toBe(false);
const openAiTools = createOpenClawCodingTools({
config: testConfig,
modelProvider: "openai",
modelId: "gpt-5.4",
});
expect(openAiTools.some((tool) => tool.name === "apply_patch")).toBe(true);
const codexTools = createOpenClawCodingTools({
config: testConfig,
modelProvider: "openai-codex",
modelId: "gpt-5.4",
});
expect(codexTools.some((tool) => tool.name === "apply_patch")).toBe(true);
const disabledConfig: OpenClawConfig = {
tools: {
exec: {
applyPatch: { enabled: false },
},
},
};
const disabledOpenAiTools = createOpenClawCodingTools({
config: disabledConfig,
modelProvider: "openai",
modelId: "gpt-5.4",
});
expect(disabledOpenAiTools.some((tool) => tool.name === "apply_patch")).toBe(false);
const anthropicTools = createOpenClawCodingTools({
config: disabledConfig,
modelProvider: "anthropic",
modelId: "claude-opus-4-6",
});
expect(anthropicTools.some((tool) => tool.name === "apply_patch")).toBe(false);
const allowModelsConfig: OpenClawConfig = {
tools: {
exec: {
applyPatch: { allowModels: ["gpt-5.4"] },
},
},
};
const allowed = createOpenClawCodingTools({
config: allowModelsConfig,
modelProvider: "openai",
modelId: "gpt-5.4",
});
expect(allowed.some((tool) => tool.name === "apply_patch")).toBe(true);
const denied = createOpenClawCodingTools({
config: allowModelsConfig,
modelProvider: "openai",
modelId: "gpt-5.4-mini",
});
expect(denied.some((tool) => tool.name === "apply_patch")).toBe(false);
const oauthTools = createOpenClawCodingTools({
config: testConfig,
modelProvider: "anthropic",
modelAuthMode: "oauth",
});
const names = new Set(oauthTools.map((tool) => tool.name));
expect(names.has("exec")).toBe(true);
expect(names.has("read")).toBe(true);
expect(names.has("write")).toBe(true);
expect(names.has("edit")).toBe(true);
expect(names.has("apply_patch")).toBe(false);
});
it("provides top-level object schemas for all tools", () => {
const tools = createOpenClawCodingTools({ config: testConfig });
const offenders = tools
.map((tool) => {
const schema =
tool.parameters && typeof tool.parameters === "object"
? (tool.parameters as Record<string, unknown>)
: null;
return {
name: tool.name,
type: schema?.type,
keys: schema ? Object.keys(schema).toSorted() : null,
};
})
.filter((entry) => entry.type !== "object");
expect(offenders).toEqual([]);
});
});

View File

@@ -1,189 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
applyXaiModelCompat,
findUnsupportedSchemaKeywords,
GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS,
XAI_UNSUPPORTED_SCHEMA_KEYWORDS,
} from "../plugin-sdk/provider-tools.js";
import "./test-helpers/fast-bash-tools.js";
import "./test-helpers/fast-coding-tools.js";
import "./test-helpers/fast-openclaw-tools.js";
import { createOpenClawCodingTools } from "./pi-tools.js";
describe("createOpenClawCodingTools", () => {
it("does not expose provider-specific message tools", () => {
const tools = createOpenClawCodingTools({ messageProvider: "discord" });
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("discord")).toBe(false);
expect(names.has("slack")).toBe(false);
expect(names.has("telegram")).toBe(false);
expect(names.has("whatsapp")).toBe(false);
});
it("filters session tools for sub-agent sessions by default", () => {
const tools = createOpenClawCodingTools({
sessionKey: "agent:main:subagent:test",
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("sessions_list")).toBe(false);
expect(names.has("sessions_history")).toBe(false);
expect(names.has("sessions_send")).toBe(false);
expect(names.has("sessions_spawn")).toBe(false);
expect(names.has("subagents")).toBe(false);
expect(names.has("read")).toBe(true);
expect(names.has("exec")).toBe(true);
expect(names.has("process")).toBe(true);
expect(names.has("apply_patch")).toBe(false);
});
it("uses stored spawnDepth to apply leaf tool policy for flat depth-2 session keys", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-depth-policy-"));
const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json");
const storePath = storeTemplate.replaceAll("{agentId}", "main");
await fs.writeFile(
storePath,
JSON.stringify(
{
"agent:main:subagent:flat": {
sessionId: "session-flat-depth-2",
updatedAt: Date.now(),
spawnDepth: 2,
},
},
null,
2,
),
"utf-8",
);
const tools = createOpenClawCodingTools({
sessionKey: "agent:main:subagent:flat",
config: {
session: {
store: storeTemplate,
},
agents: {
defaults: {
subagents: {
maxSpawnDepth: 2,
},
},
},
},
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("sessions_spawn")).toBe(false);
expect(names.has("sessions_list")).toBe(false);
expect(names.has("sessions_history")).toBe(false);
expect(names.has("subagents")).toBe(false);
});
it("supports allow-only sub-agent tool policy", () => {
const tools = createOpenClawCodingTools({
sessionKey: "agent:main:subagent:test",
config: {
tools: {
subagents: {
tools: {
allow: ["read"],
},
},
},
},
});
expect(tools.map((tool) => tool.name)).toEqual(["read"]);
});
it("applies tool profiles before allow/deny policies", () => {
const tools = createOpenClawCodingTools({
config: { tools: { profile: "messaging" } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("message")).toBe(true);
expect(names.has("sessions_send")).toBe(true);
expect(names.has("sessions_spawn")).toBe(false);
expect(names.has("exec")).toBe(false);
expect(names.has("browser")).toBe(false);
});
it("expands group shorthands in global tool policy", () => {
const tools = createOpenClawCodingTools({
config: { tools: { allow: ["group:fs"] } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("read")).toBe(true);
expect(names.has("write")).toBe(true);
expect(names.has("edit")).toBe(true);
expect(names.has("exec")).toBe(false);
expect(names.has("browser")).toBe(false);
});
it("expands group shorthands in global tool deny policy", () => {
const tools = createOpenClawCodingTools({
config: { tools: { deny: ["group:fs"] } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("read")).toBe(false);
expect(names.has("write")).toBe(false);
expect(names.has("edit")).toBe(false);
expect(names.has("exec")).toBe(true);
});
it("lets agent profiles override global profiles", () => {
const tools = createOpenClawCodingTools({
sessionKey: "agent:work:main",
config: {
tools: { profile: "coding" },
agents: {
list: [{ id: "work", tools: { profile: "messaging" } }],
},
},
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("message")).toBe(true);
expect(names.has("exec")).toBe(false);
expect(names.has("read")).toBe(false);
});
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
const googleTools = createOpenClawCodingTools({
modelProvider: "google",
senderIsOwner: true,
});
for (const tool of googleTools) {
const violations = findUnsupportedSchemaKeywords(
tool.parameters,
`${tool.name}.parameters`,
GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS,
);
expect(violations).toEqual([]);
}
});
it("applies xai model compat for direct Grok tool cleanup", () => {
const xaiTools = createOpenClawCodingTools({
modelProvider: "xai",
modelCompat: applyXaiModelCompat({ compat: {} }).compat,
senderIsOwner: true,
});
expect(xaiTools.some((tool) => tool.name === "web_search")).toBe(false);
for (const tool of xaiTools) {
const violations = findUnsupportedSchemaKeywords(
tool.parameters,
`${tool.name}.parameters`,
XAI_UNSUPPORTED_SCHEMA_KEYWORDS,
);
expect(
violations.filter((violation) => {
const keyword = violation.split(".").at(-1) ?? "";
return XAI_UNSUPPORTED_SCHEMA_KEYWORDS.has(keyword);
}),
).toEqual([]);
}
});
});

View File

@@ -1,99 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import "./test-helpers/fast-bash-tools.js";
import "./test-helpers/fast-coding-tools.js";
import "./test-helpers/fast-openclaw-tools.js";
import { createOpenClawCodingTools } from "./pi-tools.js";
import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js";
import { createPiToolsSandboxContext } from "./test-helpers/pi-tools-sandbox-context.js";
const tinyPngBuffer = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2f7z8AAAAASUVORK5CYII=",
"base64",
);
describe("createOpenClawCodingTools", () => {
it("returns image-aware read metadata for images and text-only blocks for text files", async () => {
const defaultTools = createOpenClawCodingTools();
const readTool = defaultTools.find((tool) => tool.name === "read");
expect(readTool).toBeDefined();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-read-"));
try {
const imagePath = path.join(tmpDir, "sample.png");
await fs.writeFile(imagePath, tinyPngBuffer);
const imageResult = await readTool?.execute("tool-1", {
path: imagePath,
});
const imageBlocks = imageResult?.content?.filter((block) => block.type === "image") as
| Array<{ mimeType?: string }>
| undefined;
const imageTextBlocks = imageResult?.content?.filter((block) => block.type === "text") as
| Array<{ text?: string }>
| undefined;
const imageText = imageTextBlocks?.map((block) => block.text ?? "").join("\n") ?? "";
expect(imageText).toContain("Read image file [image/png]");
if ((imageBlocks?.length ?? 0) > 0) {
expect(imageBlocks?.every((block) => block.mimeType === "image/png")).toBe(true);
} else {
expect(imageText).toContain("[Image omitted:");
}
const textPath = path.join(tmpDir, "sample.txt");
const contents = "Hello from openclaw read tool.";
await fs.writeFile(textPath, contents, "utf8");
const textResult = await readTool?.execute("tool-2", {
path: textPath,
});
expect(textResult?.content?.some((block) => block.type === "image")).toBe(false);
const textBlocks = textResult?.content?.filter((block) => block.type === "text") as
| Array<{ text?: string }>
| undefined;
expect(textBlocks?.length ?? 0).toBeGreaterThan(0);
const combinedText = textBlocks?.map((block) => block.text ?? "").join("\n");
expect(combinedText).toContain(contents);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("filters tools by sandbox policy", () => {
const sandboxDir = path.join(os.tmpdir(), "openclaw-sandbox");
const sandbox = createPiToolsSandboxContext({
workspaceDir: sandboxDir,
agentWorkspaceDir: path.join(os.tmpdir(), "openclaw-workspace"),
workspaceAccess: "none" as const,
fsBridge: createHostSandboxFsBridge(sandboxDir),
tools: {
allow: ["bash"],
deny: ["browser"],
},
});
const tools = createOpenClawCodingTools({ sandbox });
expect(tools.some((tool) => tool.name === "exec")).toBe(true);
expect(tools.some((tool) => tool.name === "read")).toBe(false);
expect(tools.some((tool) => tool.name === "browser")).toBe(false);
});
it("hard-disables write/edit when sandbox workspaceAccess is ro", () => {
const sandboxDir = path.join(os.tmpdir(), "openclaw-sandbox");
const sandbox = createPiToolsSandboxContext({
workspaceDir: sandboxDir,
agentWorkspaceDir: path.join(os.tmpdir(), "openclaw-workspace"),
workspaceAccess: "ro" as const,
fsBridge: createHostSandboxFsBridge(sandboxDir),
tools: {
allow: ["read", "write", "edit"],
deny: [],
},
});
const tools = createOpenClawCodingTools({ sandbox });
expect(tools.some((tool) => tool.name === "read")).toBe(true);
expect(tools.some((tool) => tool.name === "write")).toBe(false);
expect(tools.some((tool) => tool.name === "edit")).toBe(false);
});
});

View File

@@ -1,122 +0,0 @@
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { describe, expect, it, vi } from "vitest";
import { REQUIRED_PARAM_GROUPS, wrapToolParamValidation } from "./pi-tools.params.js";
import { cleanToolSchemaForGemini } from "./pi-tools.schema.js";
describe("createOpenClawCodingTools", () => {
describe("Gemini cleanup and strict param validation", () => {
it("enforces canonical path/content at runtime", async () => {
const execute = vi.fn(async (_id, args) => args);
const tool: AgentTool = {
name: "write",
label: "write",
description: "test",
parameters: Type.Object({
path: Type.String(),
content: Type.String(),
}),
execute,
};
const wrapped = wrapToolParamValidation(tool, REQUIRED_PARAM_GROUPS.write);
await wrapped.execute("tool-1", { path: "foo.txt", content: "x" });
expect(execute).toHaveBeenCalledWith(
"tool-1",
{ path: "foo.txt", content: "x" },
undefined,
undefined,
);
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
await expect(wrapped.execute("tool-2", { content: "x" })).rejects.toThrow(
/Supply correct parameters before retrying\./,
);
await expect(wrapped.execute("tool-3", { path: " ", content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
await expect(wrapped.execute("tool-3", { path: " ", content: "x" })).rejects.toThrow(
/Supply correct parameters before retrying\./,
);
await expect(wrapped.execute("tool-4", {})).rejects.toThrow(
/Missing required parameters: path, content/,
);
await expect(wrapped.execute("tool-4", {})).rejects.toThrow(
/Supply correct parameters before retrying\./,
);
});
});
it("inlines local $ref before removing unsupported keywords", () => {
const cleaned = cleanToolSchemaForGemini({
type: "object",
properties: {
foo: { $ref: "#/$defs/Foo" },
},
$defs: {
Foo: { type: "string", enum: ["a", "b"] },
},
}) as {
$defs?: unknown;
properties?: Record<string, unknown>;
};
expect(cleaned.$defs).toBeUndefined();
expect(cleaned.properties).toBeDefined();
expect(cleaned.properties?.foo).toMatchObject({
type: "string",
enum: ["a", "b"],
});
});
it("cleans tuple items schemas", () => {
const cleaned = cleanToolSchemaForGemini({
type: "object",
properties: {
tuples: {
type: "array",
items: [
{ type: "string", format: "uuid" },
{ type: "number", minimum: 1 },
],
},
},
}) as {
properties?: Record<string, unknown>;
};
const tuples = cleaned.properties?.tuples as { items?: unknown } | undefined;
const items = Array.isArray(tuples?.items) ? tuples?.items : [];
const first = items[0] as { format?: unknown } | undefined;
const second = items[1] as { minimum?: unknown } | undefined;
expect(first?.format).toBeUndefined();
expect(second?.minimum).toBeUndefined();
});
it("drops null-only union variants without flattening other unions", () => {
const cleaned = cleanToolSchemaForGemini({
type: "object",
properties: {
parentId: { anyOf: [{ type: "string" }, { type: "null" }] },
count: { oneOf: [{ type: "string" }, { type: "number" }] },
},
}) as {
properties?: Record<string, unknown>;
};
const parentId = cleaned.properties?.parentId as
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
| undefined;
const count = cleaned.properties?.count as
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
| undefined;
expect(parentId?.type).toBe("string");
expect(parentId?.anyOf).toBeUndefined();
expect(count?.oneOf).toBeUndefined();
});
});

View File

@@ -1,120 +0,0 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import "./test-helpers/fast-bash-tools.js";
import "./test-helpers/fast-coding-tools.js";
import "./test-helpers/fast-openclaw-tools.js";
import { createOpenClawCodingTools } from "./pi-tools.js";
import { expectReadWriteEditTools } from "./test-helpers/pi-tools-fs-helpers.js";
describe("createOpenClawCodingTools", () => {
it("accepts canonical parameters for read/write/edit", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canonical-"));
try {
const tools = createOpenClawCodingTools({ workspaceDir: tmpDir });
const { readTool, writeTool, editTool } = expectReadWriteEditTools(tools);
const filePath = "canonical-test.txt";
await writeTool?.execute("tool-canonical-1", {
path: filePath,
content: "hello world",
});
await editTool?.execute("tool-canonical-2", {
path: filePath,
edits: [{ oldText: "world", newText: "universe" }],
});
const result = await readTool?.execute("tool-canonical-3", {
path: filePath,
});
const textBlocks = result?.content?.filter((block) => block.type === "text") as
| Array<{ text?: string }>
| undefined;
const combinedText = textBlocks?.map((block) => block.text ?? "").join("\n");
expect(combinedText).toContain("hello universe");
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("rejects legacy alias parameters", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-legacy-alias-"));
try {
const tools = createOpenClawCodingTools({ workspaceDir: tmpDir });
const { readTool, writeTool, editTool } = expectReadWriteEditTools(tools);
await expect(
writeTool?.execute("tool-legacy-write", {
file: "legacy.txt",
content: "hello old value",
}),
).rejects.toThrow(/Missing required parameter: path/);
await expect(
editTool?.execute("tool-legacy-edit", {
filePath: "legacy.txt",
old_text: "old",
newString: "new",
}),
).rejects.toThrow(/Missing required parameters: path, edits/);
await expect(
readTool?.execute("tool-legacy-read", {
file_path: "legacy.txt",
}),
).rejects.toThrow(/Missing required parameter: path/);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("rejects structured content blocks for write", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-structured-write-"));
try {
const tools = createOpenClawCodingTools({ workspaceDir: tmpDir });
const writeTool = tools.find((tool) => tool.name === "write");
expect(writeTool).toBeDefined();
await expect(
writeTool?.execute("tool-structured-write", {
path: "structured-write.js",
content: [
{ type: "text", text: "const path = require('path');\n" },
{ type: "input_text", text: "const root = path.join(process.env.HOME, 'clawd');\n" },
],
}),
).rejects.toThrow(/Missing required parameter: content/);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("rejects structured edit payloads", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-structured-edit-"));
try {
const filePath = path.join(tmpDir, "structured-edit.js");
await fs.writeFile(filePath, "const value = 'old';\n", "utf8");
const tools = createOpenClawCodingTools({ workspaceDir: tmpDir });
const editTool = tools.find((tool) => tool.name === "edit");
expect(editTool).toBeDefined();
await expect(
editTool?.execute("tool-structured-edit", {
path: "structured-edit.js",
edits: [
{
oldText: [{ type: "text", text: "old" }],
newText: [{ kind: "text", value: "new" }],
},
],
}),
).rejects.toThrow(/Missing required parameter: edits/);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,538 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
applyXaiModelCompat,
findUnsupportedSchemaKeywords,
GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS,
XAI_UNSUPPORTED_SCHEMA_KEYWORDS,
} from "../plugin-sdk/provider-tools.js";
import "./test-helpers/fast-bash-tools.js";
import "./test-helpers/fast-coding-tools.js";
import "./test-helpers/fast-openclaw-tools.js";
import { createOpenClawCodingTools } from "./pi-tools.js";
import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js";
import { expectReadWriteEditTools } from "./test-helpers/pi-tools-fs-helpers.js";
import { createPiToolsSandboxContext } from "./test-helpers/pi-tools-sandbox-context.js";
const tinyPngBuffer = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO2f7z8AAAAASUVORK5CYII=",
"base64",
);
function collectActionValues(schema: unknown, values: Set<string>): void {
if (!schema || typeof schema !== "object") {
return;
}
const record = schema as Record<string, unknown>;
if (typeof record.const === "string") {
values.add(record.const);
}
if (Array.isArray(record.enum)) {
for (const value of record.enum) {
if (typeof value === "string") {
values.add(value);
}
}
}
if (Array.isArray(record.anyOf)) {
for (const variant of record.anyOf) {
collectActionValues(variant, values);
}
}
}
describe("createOpenClawCodingTools", () => {
const testConfig: OpenClawConfig = {};
it("preserves action enums in normalized schemas", () => {
const defaultTools = createOpenClawCodingTools({ config: testConfig, senderIsOwner: true });
const toolNames = ["canvas", "nodes", "cron", "gateway", "message"];
const missingNames = toolNames.filter(
(name) => !defaultTools.some((candidate) => candidate.name === name),
);
expect(missingNames).toEqual([]);
for (const name of toolNames) {
const tool = defaultTools.find((candidate) => candidate.name === name);
const parameters = tool?.parameters as {
properties?: Record<string, unknown>;
};
const action = parameters.properties?.action as
| { const?: unknown; enum?: unknown[] }
| undefined;
const values = new Set<string>();
collectActionValues(action, values);
const min = name === "gateway" ? 1 : 2;
expect(values.size).toBeGreaterThanOrEqual(min);
}
});
it("enforces apply_patch availability and canonical names across model/provider constraints", () => {
const defaultTools = createOpenClawCodingTools({ config: testConfig, senderIsOwner: true });
expect(defaultTools.some((tool) => tool.name === "exec")).toBe(true);
expect(defaultTools.some((tool) => tool.name === "process")).toBe(true);
expect(defaultTools.some((tool) => tool.name === "apply_patch")).toBe(false);
const openAiTools = createOpenClawCodingTools({
config: testConfig,
modelProvider: "openai",
modelId: "gpt-5.4",
});
expect(openAiTools.some((tool) => tool.name === "apply_patch")).toBe(true);
const codexTools = createOpenClawCodingTools({
config: testConfig,
modelProvider: "openai-codex",
modelId: "gpt-5.4",
});
expect(codexTools.some((tool) => tool.name === "apply_patch")).toBe(true);
const disabledConfig: OpenClawConfig = {
tools: {
exec: {
applyPatch: { enabled: false },
},
},
};
const disabledOpenAiTools = createOpenClawCodingTools({
config: disabledConfig,
modelProvider: "openai",
modelId: "gpt-5.4",
});
expect(disabledOpenAiTools.some((tool) => tool.name === "apply_patch")).toBe(false);
const anthropicTools = createOpenClawCodingTools({
config: disabledConfig,
modelProvider: "anthropic",
modelId: "claude-opus-4-6",
});
expect(anthropicTools.some((tool) => tool.name === "apply_patch")).toBe(false);
const allowModelsConfig: OpenClawConfig = {
tools: {
exec: {
applyPatch: { allowModels: ["gpt-5.4"] },
},
},
};
const allowed = createOpenClawCodingTools({
config: allowModelsConfig,
modelProvider: "openai",
modelId: "gpt-5.4",
});
expect(allowed.some((tool) => tool.name === "apply_patch")).toBe(true);
const denied = createOpenClawCodingTools({
config: allowModelsConfig,
modelProvider: "openai",
modelId: "gpt-5.4-mini",
});
expect(denied.some((tool) => tool.name === "apply_patch")).toBe(false);
const oauthTools = createOpenClawCodingTools({
config: testConfig,
modelProvider: "anthropic",
modelAuthMode: "oauth",
});
const names = new Set(oauthTools.map((tool) => tool.name));
expect(names.has("exec")).toBe(true);
expect(names.has("read")).toBe(true);
expect(names.has("write")).toBe(true);
expect(names.has("edit")).toBe(true);
expect(names.has("apply_patch")).toBe(false);
});
it("provides top-level object schemas for all tools", () => {
const tools = createOpenClawCodingTools({ config: testConfig });
const offenders = tools
.map((tool) => {
const schema =
tool.parameters && typeof tool.parameters === "object"
? (tool.parameters as Record<string, unknown>)
: null;
return {
name: tool.name,
type: schema?.type,
keys: schema ? Object.keys(schema).toSorted() : null,
};
})
.filter((entry) => entry.type !== "object");
expect(offenders).toEqual([]);
});
it("does not expose provider-specific message tools", () => {
const tools = createOpenClawCodingTools({ messageProvider: "discord" });
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("discord")).toBe(false);
expect(names.has("slack")).toBe(false);
expect(names.has("telegram")).toBe(false);
expect(names.has("whatsapp")).toBe(false);
});
it("filters session tools for sub-agent sessions by default", () => {
const tools = createOpenClawCodingTools({
sessionKey: "agent:main:subagent:test",
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("sessions_list")).toBe(false);
expect(names.has("sessions_history")).toBe(false);
expect(names.has("sessions_send")).toBe(false);
expect(names.has("sessions_spawn")).toBe(false);
expect(names.has("subagents")).toBe(false);
expect(names.has("read")).toBe(true);
expect(names.has("exec")).toBe(true);
expect(names.has("process")).toBe(true);
expect(names.has("apply_patch")).toBe(false);
});
it("uses stored spawnDepth to apply leaf tool policy for flat depth-2 session keys", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-depth-policy-"));
try {
const storeTemplate = path.join(tmpDir, "sessions-{agentId}.json");
const storePath = storeTemplate.replaceAll("{agentId}", "main");
await fs.writeFile(
storePath,
JSON.stringify(
{
"agent:main:subagent:flat": {
sessionId: "session-flat-depth-2",
updatedAt: Date.now(),
spawnDepth: 2,
},
},
null,
2,
),
"utf-8",
);
const tools = createOpenClawCodingTools({
sessionKey: "agent:main:subagent:flat",
config: {
session: {
store: storeTemplate,
},
agents: {
defaults: {
subagents: {
maxSpawnDepth: 2,
},
},
},
},
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("sessions_spawn")).toBe(false);
expect(names.has("sessions_list")).toBe(false);
expect(names.has("sessions_history")).toBe(false);
expect(names.has("subagents")).toBe(false);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("supports allow-only sub-agent tool policy", () => {
const tools = createOpenClawCodingTools({
sessionKey: "agent:main:subagent:test",
config: {
tools: {
subagents: {
tools: {
allow: ["read"],
},
},
},
},
});
expect(tools.map((tool) => tool.name)).toEqual(["read"]);
});
it("applies tool profiles before allow/deny policies", () => {
const tools = createOpenClawCodingTools({
config: { tools: { profile: "messaging" } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("message")).toBe(true);
expect(names.has("sessions_send")).toBe(true);
expect(names.has("sessions_spawn")).toBe(false);
expect(names.has("exec")).toBe(false);
expect(names.has("browser")).toBe(false);
});
it("expands group shorthands in global tool policy", () => {
const tools = createOpenClawCodingTools({
config: { tools: { allow: ["group:fs"] } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("read")).toBe(true);
expect(names.has("write")).toBe(true);
expect(names.has("edit")).toBe(true);
expect(names.has("exec")).toBe(false);
expect(names.has("browser")).toBe(false);
});
it("expands group shorthands in global tool deny policy", () => {
const tools = createOpenClawCodingTools({
config: { tools: { deny: ["group:fs"] } },
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("read")).toBe(false);
expect(names.has("write")).toBe(false);
expect(names.has("edit")).toBe(false);
expect(names.has("exec")).toBe(true);
});
it("lets agent profiles override global profiles", () => {
const tools = createOpenClawCodingTools({
sessionKey: "agent:work:main",
config: {
tools: { profile: "coding" },
agents: {
list: [{ id: "work", tools: { profile: "messaging" } }],
},
},
});
const names = new Set(tools.map((tool) => tool.name));
expect(names.has("message")).toBe(true);
expect(names.has("exec")).toBe(false);
expect(names.has("read")).toBe(false);
});
it("removes unsupported JSON Schema keywords for Cloud Code Assist API compatibility", () => {
const googleTools = createOpenClawCodingTools({
modelProvider: "google",
senderIsOwner: true,
});
for (const tool of googleTools) {
const violations = findUnsupportedSchemaKeywords(
tool.parameters,
`${tool.name}.parameters`,
GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS,
);
expect(violations).toEqual([]);
}
});
it("applies xai model compat for direct Grok tool cleanup", () => {
const xaiTools = createOpenClawCodingTools({
modelProvider: "xai",
modelCompat: applyXaiModelCompat({ compat: {} }).compat,
senderIsOwner: true,
});
expect(xaiTools.some((tool) => tool.name === "web_search")).toBe(false);
for (const tool of xaiTools) {
const violations = findUnsupportedSchemaKeywords(
tool.parameters,
`${tool.name}.parameters`,
XAI_UNSUPPORTED_SCHEMA_KEYWORDS,
);
expect(
violations.filter((violation) => {
const keyword = violation.split(".").at(-1) ?? "";
return XAI_UNSUPPORTED_SCHEMA_KEYWORDS.has(keyword);
}),
).toEqual([]);
}
});
it("returns image-aware read metadata for images and text-only blocks for text files", async () => {
const defaultTools = createOpenClawCodingTools();
const readTool = defaultTools.find((tool) => tool.name === "read");
expect(readTool).toBeDefined();
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-read-"));
try {
const imagePath = path.join(tmpDir, "sample.png");
await fs.writeFile(imagePath, tinyPngBuffer);
const imageResult = await readTool?.execute("tool-1", {
path: imagePath,
});
const imageBlocks = imageResult?.content?.filter((block) => block.type === "image") as
| Array<{ mimeType?: string }>
| undefined;
const imageTextBlocks = imageResult?.content?.filter((block) => block.type === "text") as
| Array<{ text?: string }>
| undefined;
const imageText = imageTextBlocks?.map((block) => block.text ?? "").join("\n") ?? "";
expect(imageText).toContain("Read image file [image/png]");
if ((imageBlocks?.length ?? 0) > 0) {
expect(imageBlocks?.every((block) => block.mimeType === "image/png")).toBe(true);
} else {
expect(imageText).toContain("[Image omitted:");
}
const textPath = path.join(tmpDir, "sample.txt");
const contents = "Hello from openclaw read tool.";
await fs.writeFile(textPath, contents, "utf8");
const textResult = await readTool?.execute("tool-2", {
path: textPath,
});
expect(textResult?.content?.some((block) => block.type === "image")).toBe(false);
const textBlocks = textResult?.content?.filter((block) => block.type === "text") as
| Array<{ text?: string }>
| undefined;
expect(textBlocks?.length ?? 0).toBeGreaterThan(0);
const combinedText = textBlocks?.map((block) => block.text ?? "").join("\n");
expect(combinedText).toContain(contents);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("filters tools by sandbox policy", () => {
const sandboxDir = path.join(os.tmpdir(), "openclaw-sandbox");
const sandbox = createPiToolsSandboxContext({
workspaceDir: sandboxDir,
agentWorkspaceDir: path.join(os.tmpdir(), "openclaw-workspace"),
workspaceAccess: "none" as const,
fsBridge: createHostSandboxFsBridge(sandboxDir),
tools: {
allow: ["bash"],
deny: ["browser"],
},
});
const tools = createOpenClawCodingTools({ sandbox });
expect(tools.some((tool) => tool.name === "exec")).toBe(true);
expect(tools.some((tool) => tool.name === "read")).toBe(false);
expect(tools.some((tool) => tool.name === "browser")).toBe(false);
});
it("hard-disables write/edit when sandbox workspaceAccess is ro", () => {
const sandboxDir = path.join(os.tmpdir(), "openclaw-sandbox");
const sandbox = createPiToolsSandboxContext({
workspaceDir: sandboxDir,
agentWorkspaceDir: path.join(os.tmpdir(), "openclaw-workspace"),
workspaceAccess: "ro" as const,
fsBridge: createHostSandboxFsBridge(sandboxDir),
tools: {
allow: ["read", "write", "edit"],
deny: [],
},
});
const tools = createOpenClawCodingTools({ sandbox });
expect(tools.some((tool) => tool.name === "read")).toBe(true);
expect(tools.some((tool) => tool.name === "write")).toBe(false);
expect(tools.some((tool) => tool.name === "edit")).toBe(false);
});
it("accepts canonical parameters for read/write/edit", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-canonical-"));
try {
const tools = createOpenClawCodingTools({ workspaceDir: tmpDir });
const { readTool, writeTool, editTool } = expectReadWriteEditTools(tools);
const filePath = "canonical-test.txt";
await writeTool?.execute("tool-canonical-1", {
path: filePath,
content: "hello world",
});
await editTool?.execute("tool-canonical-2", {
path: filePath,
edits: [{ oldText: "world", newText: "universe" }],
});
const result = await readTool?.execute("tool-canonical-3", {
path: filePath,
});
const textBlocks = result?.content?.filter((block) => block.type === "text") as
| Array<{ text?: string }>
| undefined;
const combinedText = textBlocks?.map((block) => block.text ?? "").join("\n");
expect(combinedText).toContain("hello universe");
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("rejects legacy alias parameters", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-legacy-alias-"));
try {
const tools = createOpenClawCodingTools({ workspaceDir: tmpDir });
const { readTool, writeTool, editTool } = expectReadWriteEditTools(tools);
await expect(
writeTool?.execute("tool-legacy-write", {
file: "legacy.txt",
content: "hello old value",
}),
).rejects.toThrow(/Missing required parameter: path/);
await expect(
editTool?.execute("tool-legacy-edit", {
filePath: "legacy.txt",
old_text: "old",
newString: "new",
}),
).rejects.toThrow(/Missing required parameters: path, edits/);
await expect(
readTool?.execute("tool-legacy-read", {
file_path: "legacy.txt",
}),
).rejects.toThrow(/Missing required parameter: path/);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("rejects structured content blocks for write", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-structured-write-"));
try {
const tools = createOpenClawCodingTools({ workspaceDir: tmpDir });
const writeTool = tools.find((tool) => tool.name === "write");
expect(writeTool).toBeDefined();
await expect(
writeTool?.execute("tool-structured-write", {
path: "structured-write.js",
content: [
{ type: "text", text: "const path = require('path');\n" },
{ type: "input_text", text: "const root = path.join(process.env.HOME, 'clawd');\n" },
],
}),
).rejects.toThrow(/Missing required parameter: content/);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
it("rejects structured edit payloads", async () => {
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-structured-edit-"));
try {
const filePath = path.join(tmpDir, "structured-edit.js");
await fs.writeFile(filePath, "const value = 'old';\n", "utf8");
const tools = createOpenClawCodingTools({ workspaceDir: tmpDir });
const editTool = tools.find((tool) => tool.name === "edit");
expect(editTool).toBeDefined();
await expect(
editTool?.execute("tool-structured-edit", {
path: "structured-edit.js",
edits: [
{
oldText: [{ type: "text", text: "old" }],
newText: [{ kind: "text", value: "new" }],
},
],
}),
).rejects.toThrow(/Missing required parameter: edits/);
} finally {
await fs.rm(tmpDir, { recursive: true, force: true });
}
});
});

View File

@@ -41,6 +41,47 @@ describe("assertRequiredParams", () => {
).rejects.toThrow(/\(received: file_path\)/);
});
it("enforces canonical path/content at runtime", async () => {
const execute = vi.fn(async (_id, args) => args);
const tool = wrapToolParamValidation(
{
name: "write",
label: "write",
description: "test",
parameters: {},
execute,
},
REQUIRED_PARAM_GROUPS.write,
);
await tool.execute("tool-1", { path: "foo.txt", content: "x" });
expect(execute).toHaveBeenCalledWith(
"tool-1",
{ path: "foo.txt", content: "x" },
undefined,
undefined,
);
await expect(tool.execute("tool-2", { content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
await expect(tool.execute("tool-2", { content: "x" })).rejects.toThrow(
/Supply correct parameters before retrying\./,
);
await expect(tool.execute("tool-3", { path: " ", content: "x" })).rejects.toThrow(
/Missing required parameter/,
);
await expect(tool.execute("tool-3", { path: " ", content: "x" })).rejects.toThrow(
/Supply correct parameters before retrying\./,
);
await expect(tool.execute("tool-4", {})).rejects.toThrow(
/Missing required parameters: path, content/,
);
await expect(tool.execute("tool-4", {})).rejects.toThrow(
/Supply correct parameters before retrying\./,
);
});
it("excludes null and undefined values from received hint", () => {
expect(() =>
assertRequiredParams(

View File

@@ -1,6 +1,10 @@
import { Type } from "@sinclair/typebox";
import { describe, expect, it, vi } from "vitest";
import { normalizeToolParameterSchema, normalizeToolParameters } from "./pi-tools.schema.js";
import {
cleanToolSchemaForGemini,
normalizeToolParameterSchema,
normalizeToolParameters,
} from "./pi-tools.schema.js";
import type { AnyAgentTool } from "./pi-tools.types.js";
describe("normalizeToolParameterSchema", () => {
@@ -31,6 +35,76 @@ describe("normalizeToolParameterSchema", () => {
required: ["q"],
});
});
it("inlines local $ref before removing unsupported keywords", () => {
const cleaned = cleanToolSchemaForGemini({
type: "object",
properties: {
foo: { $ref: "#/$defs/Foo" },
},
$defs: {
Foo: { type: "string", enum: ["a", "b"] },
},
}) as {
$defs?: unknown;
properties?: Record<string, unknown>;
};
expect(cleaned.$defs).toBeUndefined();
expect(cleaned.properties).toBeDefined();
expect(cleaned.properties?.foo).toMatchObject({
type: "string",
enum: ["a", "b"],
});
});
it("cleans tuple items schemas", () => {
const cleaned = cleanToolSchemaForGemini({
type: "object",
properties: {
tuples: {
type: "array",
items: [
{ type: "string", format: "uuid" },
{ type: "number", minimum: 1 },
],
},
},
}) as {
properties?: Record<string, unknown>;
};
const tuples = cleaned.properties?.tuples as { items?: unknown } | undefined;
const items = Array.isArray(tuples?.items) ? tuples?.items : [];
const first = items[0] as { format?: unknown } | undefined;
const second = items[1] as { minimum?: unknown } | undefined;
expect(first?.format).toBeUndefined();
expect(second?.minimum).toBeUndefined();
});
it("drops null-only union variants without flattening other unions", () => {
const cleaned = cleanToolSchemaForGemini({
type: "object",
properties: {
parentId: { anyOf: [{ type: "string" }, { type: "null" }] },
count: { oneOf: [{ type: "string" }, { type: "number" }] },
},
}) as {
properties?: Record<string, unknown>;
};
const parentId = cleaned.properties?.parentId as
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
| undefined;
const count = cleaned.properties?.count as
| { type?: unknown; anyOf?: unknown; oneOf?: unknown }
| undefined;
expect(parentId?.type).toBe("string");
expect(parentId?.anyOf).toBeUndefined();
expect(count?.oneOf).toBeUndefined();
});
});
function makeTool(parameters: unknown): AnyAgentTool {