From e9bf9fde0611fcac8e93ad9943f0750a73586ef0 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 5 Apr 2026 13:09:06 +0100 Subject: [PATCH] test: split legacy pi-tools schema shards --- ...aliases-schemas-without-dropping-c.test.ts | 188 ++++++ ...aliases-schemas-without-dropping-d.test.ts | 1 + ...aliases-schemas-without-dropping-e.test.ts | 154 +++++ ...aliases-schemas-without-dropping-f.test.ts | 1 + ...aliases-schemas-without-dropping-g.test.ts | 133 ++++ ...e-aliases-schemas-without-dropping.test.ts | 588 ------------------ .../test-helpers/fast-openclaw-tools.ts | 1 - 7 files changed, 477 insertions(+), 589 deletions(-) create mode 100644 src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-c.test.ts create mode 100644 src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-e.test.ts create mode 100644 src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-g.test.ts delete mode 100644 src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-c.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-c.test.ts new file mode 100644 index 00000000000..7f4c34581e2 --- /dev/null +++ b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-c.test.ts @@ -0,0 +1,188 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { applyXaiModelCompat } from "../../extensions/xai/api.js"; +import { + findUnsupportedSchemaKeywords, + GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS, + XAI_UNSUPPORTED_SCHEMA_KEYWORDS, +} from "../plugin-sdk/provider-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([]); + } + }); +}); diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts index 1c78284ad8a..7dac1b491cb 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-d.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; 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"; diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-e.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-e.test.ts new file mode 100644 index 00000000000..acc12200838 --- /dev/null +++ b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-e.test.ts @@ -0,0 +1,154 @@ +import type { AgentTool } from "@mariozechner/pi-agent-core"; +import { Type } from "@sinclair/typebox"; +import { describe, expect, it, vi } from "vitest"; +import { + patchToolSchemaForClaudeCompatibility, + wrapToolParamNormalization, +} from "./pi-tools.params.js"; +import { cleanToolSchemaForGemini } from "./pi-tools.schema.js"; + +describe("createOpenClawCodingTools", () => { + describe("Claude/Gemini alias support", () => { + it("adds Claude-style aliases to schemas without dropping metadata", () => { + const base: AgentTool = { + name: "write", + label: "write", + description: "test", + parameters: Type.Object({ + path: Type.String({ description: "Path" }), + content: Type.String({ description: "Body" }), + }), + execute: vi.fn(), + }; + + const patched = patchToolSchemaForClaudeCompatibility(base); + const params = patched.parameters as { + additionalProperties?: unknown; + properties?: Record; + required?: string[]; + }; + const props = params.properties ?? {}; + + expect(props.file_path).toEqual(props.path); + expect(params.additionalProperties).toBe(false); + expect(params.required ?? []).not.toContain("path"); + expect(params.required ?? []).not.toContain("file_path"); + }); + + it("normalizes file_path to path and enforces required groups 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 = wrapToolParamNormalization(tool, [ + { keys: ["path", "file_path"], label: "path (path or file_path)" }, + { keys: ["content"], label: "content" }, + ]); + + await wrapped.execute("tool-1", { file_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", { file_path: " ", content: "x" })).rejects.toThrow( + /Missing required parameter/, + ); + await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow( + /Supply correct parameters before retrying\./, + ); + await expect(wrapped.execute("tool-4", {})).rejects.toThrow( + /Missing required parameters: path \(path or file_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; + }; + + 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; + }; + + 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; + }; + + 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(); + }); +}); diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts index d2b5620e9d7..4caf5936bf7 100644 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts +++ b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-f.test.ts @@ -3,6 +3,7 @@ import os from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; 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"; diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-g.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-g.test.ts new file mode 100644 index 00000000000..b8bb6bea971 --- /dev/null +++ b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping-g.test.ts @@ -0,0 +1,133 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; +import { Type } from "@sinclair/typebox"; +import { describe, expect, it, vi } from "vitest"; +import { createOpenClawReadTool, createSandboxedReadTool } from "./pi-tools.read.js"; +import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js"; + +function extractToolText(result: unknown): string { + if (!result || typeof result !== "object") { + return ""; + } + const content = (result as { content?: unknown }).content; + if (!Array.isArray(content)) { + return ""; + } + const textBlock = content.find((block) => { + return ( + block && + typeof block === "object" && + (block as { type?: unknown }).type === "text" && + typeof (block as { text?: unknown }).text === "string" + ); + }) as { text?: string } | undefined; + return textBlock?.text ?? ""; +} + +describe("createOpenClawCodingTools read behavior", () => { + it("applies sandbox path guards to file_path alias", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sbx-")); + const outsidePath = path.join(os.tmpdir(), "openclaw-outside.txt"); + await fs.writeFile(outsidePath, "outside", "utf8"); + try { + const readTool = createSandboxedReadTool({ + root: tmpDir, + bridge: createHostSandboxFsBridge(tmpDir), + }); + await expect(readTool.execute("sandbox-1", { file_path: outsidePath })).rejects.toThrow( + /sandbox root/i, + ); + } finally { + await fs.rm(outsidePath, { force: true }); + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("auto-pages read output across chunks when context window budget allows", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-read-autopage-")); + const filePath = path.join(tmpDir, "big.txt"); + const lines = Array.from( + { length: 5000 }, + (_unused, i) => `line-${String(i + 1).padStart(4, "0")}`, + ); + await fs.writeFile(filePath, lines.join("\n"), "utf8"); + try { + const readTool = createSandboxedReadTool({ + root: tmpDir, + bridge: createHostSandboxFsBridge(tmpDir), + modelContextWindowTokens: 200_000, + }); + const result = await readTool.execute("read-autopage-1", { path: "big.txt" }); + const text = extractToolText(result); + expect(text).toContain("line-0001"); + expect(text).toContain("line-5000"); + expect(text).not.toContain("Read output capped at"); + expect(text).not.toMatch(/Use offset=\d+ to continue\.\]$/); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("adds capped continuation guidance when aggregated read output reaches budget", async () => { + const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-read-cap-")); + const filePath = path.join(tmpDir, "huge.txt"); + const lines = Array.from( + { length: 8000 }, + (_unused, i) => `line-${String(i + 1).padStart(4, "0")}-abcdefghijklmnopqrstuvwxyz`, + ); + await fs.writeFile(filePath, lines.join("\n"), "utf8"); + try { + const readTool = createSandboxedReadTool({ + root: tmpDir, + bridge: createHostSandboxFsBridge(tmpDir), + }); + const result = await readTool.execute("read-cap-1", { path: "huge.txt" }); + const text = extractToolText(result); + expect(text).toContain("line-0001"); + expect(text).toContain("[Read output capped at 50KB for this call. Use offset="); + expect(text).not.toContain("line-8000"); + } finally { + await fs.rm(tmpDir, { recursive: true, force: true }); + } + }); + + it("strips truncation.content details from read results while preserving other fields", async () => { + const readResult: AgentToolResult = { + content: [{ type: "text" as const, text: "line-0001" }], + details: { + truncation: { + truncated: true, + outputLines: 1, + firstLineExceedsLimit: false, + content: "hidden duplicate payload", + }, + }, + }; + const baseRead: AgentTool = { + name: "read", + label: "read", + description: "test read", + parameters: Type.Object({ + path: Type.String(), + offset: Type.Optional(Type.Number()), + limit: Type.Optional(Type.Number()), + }), + execute: vi.fn(async () => readResult), + }; + + const wrapped = createOpenClawReadTool( + baseRead as unknown as Parameters[0], + ); + const result = await wrapped.execute("read-strip-1", { path: "demo.txt", limit: 1 }); + + const details = (result as { details?: { truncation?: Record } }).details; + expect(details?.truncation).toMatchObject({ + truncated: true, + outputLines: 1, + firstLineExceedsLimit: false, + }); + expect(details?.truncation).not.toHaveProperty("content"); + }); +}); diff --git a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts b/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts deleted file mode 100644 index 98a3bfbd918..00000000000 --- a/src/agents/pi-tools.create-openclaw-coding-tools.adds-claude-style-aliases-schemas-without-dropping.test.ts +++ /dev/null @@ -1,588 +0,0 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; -import type { AgentTool, AgentToolResult } from "@mariozechner/pi-agent-core"; -import { Type } from "@sinclair/typebox"; -import { describe, expect, it, vi } from "vitest"; -import { createBrowserTool } from "../../extensions/browser/runtime-api.js"; -import { applyXaiModelCompat } from "../../extensions/xai/api.js"; -import { - findUnsupportedSchemaKeywords, - GEMINI_UNSUPPORTED_SCHEMA_KEYWORDS, - XAI_UNSUPPORTED_SCHEMA_KEYWORDS, -} from "../plugin-sdk/provider-tools.js"; -import "./test-helpers/fast-coding-tools.js"; -import { createOpenClawTools } from "./openclaw-tools.js"; -import { __testing, createOpenClawCodingTools } from "./pi-tools.js"; -import { createOpenClawReadTool, createSandboxedReadTool } from "./pi-tools.read.js"; -import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js"; - -const defaultTools = createOpenClawCodingTools(); - -function findUnionKeywordOffenders( - tools: Array<{ name: string; parameters: unknown }>, - opts?: { onlyNames?: Set }, -) { - const offenders: Array<{ - name: string; - keyword: string; - path: string; - }> = []; - const keywords = new Set(["anyOf", "oneOf", "allOf"]); - - const walk = (value: unknown, path: string, name: string): void => { - if (!value) { - return; - } - if (Array.isArray(value)) { - for (const [index, entry] of value.entries()) { - walk(entry, `${path}[${index}]`, name); - } - return; - } - if (typeof value !== "object") { - return; - } - - const record = value as Record; - for (const [key, entry] of Object.entries(record)) { - const nextPath = path ? `${path}.${key}` : key; - if (keywords.has(key)) { - offenders.push({ name, keyword: key, path: nextPath }); - } - walk(entry, nextPath, name); - } - }; - - for (const tool of tools) { - if (opts?.onlyNames && !opts.onlyNames.has(tool.name)) { - continue; - } - walk(tool.parameters, "", tool.name); - } - - return offenders; -} - -function extractToolText(result: unknown): string { - if (!result || typeof result !== "object") { - return ""; - } - const content = (result as { content?: unknown }).content; - if (!Array.isArray(content)) { - return ""; - } - const textBlock = content.find((block) => { - return ( - block && - typeof block === "object" && - (block as { type?: unknown }).type === "text" && - typeof (block as { text?: unknown }).text === "string" - ); - }) as { text?: string } | undefined; - return textBlock?.text ?? ""; -} - -describe("createOpenClawCodingTools", () => { - describe("Claude/Gemini alias support", () => { - it("adds Claude-style aliases to schemas without dropping metadata", () => { - const base: AgentTool = { - name: "write", - label: "write", - description: "test", - parameters: Type.Object({ - path: Type.String({ description: "Path" }), - content: Type.String({ description: "Body" }), - }), - execute: vi.fn(), - }; - - const patched = __testing.patchToolSchemaForClaudeCompatibility(base); - const params = patched.parameters as { - additionalProperties?: unknown; - properties?: Record; - required?: string[]; - }; - const props = params.properties ?? {}; - - expect(props.file_path).toEqual(props.path); - expect(params.additionalProperties).toBe(false); - expect(params.required ?? []).not.toContain("path"); - expect(params.required ?? []).not.toContain("file_path"); - }); - - it("normalizes file_path to path and enforces required groups 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 = __testing.wrapToolParamNormalization(tool, [ - { keys: ["path", "file_path"], label: "path (path or file_path)" }, - { keys: ["content"], label: "content" }, - ]); - - await wrapped.execute("tool-1", { file_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", { file_path: " ", content: "x" })).rejects.toThrow( - /Missing required parameter/, - ); - await expect(wrapped.execute("tool-3", { file_path: " ", content: "x" })).rejects.toThrow( - /Supply correct parameters before retrying\./, - ); - await expect(wrapped.execute("tool-4", {})).rejects.toThrow( - /Missing required parameters: path \(path or file_path\), content/, - ); - await expect(wrapped.execute("tool-4", {})).rejects.toThrow( - /Supply correct parameters before retrying\./, - ); - }); - }); - - it("keeps browser tool schema OpenAI-compatible without normalization", () => { - const browser = createBrowserTool(); - const schema = browser.parameters as { type?: unknown; anyOf?: unknown }; - expect(schema.type).toBe("object"); - expect(schema.anyOf).toBeUndefined(); - }); - it("mentions user browser profile in browser tool description", () => { - const browser = createBrowserTool(); - expect(browser.description).toMatch(/profile="user"/i); - }); - it("keeps browser tool schema properties after normalization", () => { - const browser = createBrowserTool(); - const parameters = browser.parameters as { - anyOf?: unknown[]; - properties?: Record; - required?: string[]; - }; - expect(parameters.properties?.action).toBeDefined(); - expect(parameters.properties?.target).toBeDefined(); - expect(parameters.properties?.targetUrl).toBeDefined(); - expect(parameters.properties?.request).toBeDefined(); - expect(parameters.required ?? []).toContain("action"); - }); - it("exposes raw for gateway config.apply tool calls", () => { - const gateway = createOpenClawCodingTools({ senderIsOwner: true }).find( - (tool) => tool.name === "gateway", - ); - expect(gateway).toBeDefined(); - - const parameters = gateway?.parameters as { - type?: unknown; - required?: string[]; - properties?: Record; - }; - expect(parameters.type).toBe("object"); - expect(parameters.properties?.raw).toBeDefined(); - expect(parameters.required ?? []).not.toContain("raw"); - }); - it("flattens anyOf-of-literals to enum for provider compatibility", () => { - const browser = createBrowserTool(); - const parameters = browser.parameters as { - properties?: Record; - }; - const action = parameters.properties?.action as - | { - type?: unknown; - enum?: unknown[]; - anyOf?: unknown[]; - } - | undefined; - - expect(action?.type).toBe("string"); - expect(action?.anyOf).toBeUndefined(); - expect(Array.isArray(action?.enum)).toBe(true); - expect(action?.enum).toContain("act"); - - const snapshotFormat = parameters.properties?.snapshotFormat as - | { - type?: unknown; - enum?: unknown[]; - anyOf?: unknown[]; - } - | undefined; - expect(snapshotFormat?.type).toBe("string"); - expect(snapshotFormat?.anyOf).toBeUndefined(); - expect(snapshotFormat?.enum).toEqual(["aria", "ai"]); - }); - it("inlines local $ref before removing unsupported keywords", () => { - const cleaned = __testing.cleanToolSchemaForGemini({ - type: "object", - properties: { - foo: { $ref: "#/$defs/Foo" }, - }, - $defs: { - Foo: { type: "string", enum: ["a", "b"] }, - }, - }) as { - $defs?: unknown; - properties?: Record; - }; - - 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 = __testing.cleanToolSchemaForGemini({ - type: "object", - properties: { - tuples: { - type: "array", - items: [ - { type: "string", format: "uuid" }, - { type: "number", minimum: 1 }, - ], - }, - }, - }) as { - properties?: Record; - }; - - 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 = __testing.cleanToolSchemaForGemini({ - type: "object", - properties: { - parentId: { anyOf: [{ type: "string" }, { type: "null" }] }, - count: { oneOf: [{ type: "string" }, { type: "number" }] }, - }, - }) as { - properties?: Record; - }; - - 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(); - }); - it("avoids anyOf/oneOf/allOf in tool schemas", () => { - expect(findUnionKeywordOffenders(defaultTools)).toEqual([]); - }); - it("keeps raw core tool schemas union-free", () => { - const tools = createOpenClawTools(); - const coreTools = new Set([ - "browser", - "canvas", - "nodes", - "cron", - "message", - "gateway", - "agents_list", - "sessions_list", - "sessions_history", - "sessions_send", - "sessions_spawn", - "subagents", - "session_status", - "image", - ]); - expect(findUnionKeywordOffenders(tools, { onlyNames: coreTools })).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-")); - 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", - // Intentionally partial config; only fields used by pi-tools are provided. - config: { - tools: { - subagents: { - tools: { - // Policy matching is case-insensitive - 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("applies sandbox path guards to file_path alias", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sbx-")); - const outsidePath = path.join(os.tmpdir(), "openclaw-outside.txt"); - await fs.writeFile(outsidePath, "outside", "utf8"); - try { - const readTool = createSandboxedReadTool({ - root: tmpDir, - bridge: createHostSandboxFsBridge(tmpDir), - }); - await expect(readTool.execute("sandbox-1", { file_path: outsidePath })).rejects.toThrow( - /sandbox root/i, - ); - } finally { - await fs.rm(outsidePath, { force: true }); - await fs.rm(tmpDir, { recursive: true, force: true }); - } - }); - - it("auto-pages read output across chunks when context window budget allows", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-read-autopage-")); - const filePath = path.join(tmpDir, "big.txt"); - const lines = Array.from( - { length: 5000 }, - (_unused, i) => `line-${String(i + 1).padStart(4, "0")}`, - ); - await fs.writeFile(filePath, lines.join("\n"), "utf8"); - try { - const readTool = createSandboxedReadTool({ - root: tmpDir, - bridge: createHostSandboxFsBridge(tmpDir), - modelContextWindowTokens: 200_000, - }); - const result = await readTool.execute("read-autopage-1", { path: "big.txt" }); - const text = extractToolText(result); - expect(text).toContain("line-0001"); - expect(text).toContain("line-5000"); - expect(text).not.toContain("Read output capped at"); - expect(text).not.toMatch(/Use offset=\d+ to continue\.\]$/); - } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); - } - }); - - it("adds capped continuation guidance when aggregated read output reaches budget", async () => { - const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-read-cap-")); - const filePath = path.join(tmpDir, "huge.txt"); - const lines = Array.from( - { length: 8000 }, - (_unused, i) => `line-${String(i + 1).padStart(4, "0")}-abcdefghijklmnopqrstuvwxyz`, - ); - await fs.writeFile(filePath, lines.join("\n"), "utf8"); - try { - const readTool = createSandboxedReadTool({ - root: tmpDir, - bridge: createHostSandboxFsBridge(tmpDir), - }); - const result = await readTool.execute("read-cap-1", { path: "huge.txt" }); - const text = extractToolText(result); - expect(text).toContain("line-0001"); - expect(text).toContain("[Read output capped at 50KB for this call. Use offset="); - expect(text).not.toContain("line-8000"); - } finally { - await fs.rm(tmpDir, { recursive: true, force: true }); - } - }); - - it("strips truncation.content details from read results while preserving other fields", async () => { - const readResult: AgentToolResult = { - content: [{ type: "text" as const, text: "line-0001" }], - details: { - truncation: { - truncated: true, - outputLines: 1, - firstLineExceedsLimit: false, - content: "hidden duplicate payload", - }, - }, - }; - const baseRead: AgentTool = { - name: "read", - label: "read", - description: "test read", - parameters: Type.Object({ - path: Type.String(), - offset: Type.Optional(Type.Number()), - limit: Type.Optional(Type.Number()), - }), - execute: vi.fn(async () => readResult), - }; - - const wrapped = createOpenClawReadTool( - baseRead as unknown as Parameters[0], - ); - const result = await wrapped.execute("read-strip-1", { path: "demo.txt", limit: 1 }); - - const details = (result as { details?: { truncation?: Record } }).details; - expect(details?.truncation).toMatchObject({ - truncated: true, - outputLines: 1, - firstLineExceedsLimit: false, - }); - expect(details?.truncation).not.toHaveProperty("content"); - }); -}); diff --git a/src/agents/test-helpers/fast-openclaw-tools.ts b/src/agents/test-helpers/fast-openclaw-tools.ts index ffbcc8a33f9..408b8148491 100644 --- a/src/agents/test-helpers/fast-openclaw-tools.ts +++ b/src/agents/test-helpers/fast-openclaw-tools.ts @@ -32,7 +32,6 @@ const coreTools = [ stubActionTool("session_status", ["get", "show"]), stubTool("tts"), stubTool("image_generate"), - stubTool("web_search"), stubTool("web_fetch"), stubTool("image"), stubTool("pdf"),