openclaw/src/agents/claude-agent-sdk/tool-bridge.test.ts

482 lines
14 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import type { AgentToolResult } from "@mariozechner/pi-agent-core";
// Mock dependencies
vi.mock("../../logging/subsystem.js", () => ({
createSubsystemLogger: vi.fn(() => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
}));
vi.mock("../tool-policy.js", () => ({
normalizeToolName: vi.fn((name: string) => name.toLowerCase().replace(/-/g, "_")),
}));
import {
extractJsonSchema,
convertToolResult,
wrapToolHandler,
mcpToolName,
buildMcpAllowedTools,
bridgeMoltbotToolsSync,
resetMcpServerCache,
} from "./tool-bridge.js";
import type { AnyAgentTool } from "../tools/common.js";
describe("tool-bridge", () => {
beforeEach(() => {
vi.clearAllMocks();
resetMcpServerCache();
});
afterEach(() => {
vi.resetAllMocks();
});
describe("extractJsonSchema", () => {
it("extracts JSON Schema from TypeBox schema", () => {
const tool = {
name: "test_tool",
parameters: {
type: "object",
properties: {
input: { type: "string" },
},
required: ["input"],
},
} as unknown as AnyAgentTool;
const schema = extractJsonSchema(tool);
expect(schema).toEqual({
type: "object",
properties: {
input: { type: "string" },
},
required: ["input"],
});
});
it("returns empty object schema when no parameters defined", () => {
const tool = {
name: "no_params_tool",
parameters: undefined,
} as unknown as AnyAgentTool;
const schema = extractJsonSchema(tool);
expect(schema).toEqual({ type: "object", properties: {} });
});
it("returns empty object schema for null parameters", () => {
const tool = {
name: "null_params_tool",
parameters: null,
} as unknown as AnyAgentTool;
const schema = extractJsonSchema(tool);
expect(schema).toEqual({ type: "object", properties: {} });
});
it("strips non-serializable properties from schema", () => {
const symbolKey = Symbol("internal");
const tool = {
name: "symbol_tool",
parameters: {
type: "object",
[symbolKey]: "should be stripped",
properties: {},
},
} as unknown as AnyAgentTool;
const schema = extractJsonSchema(tool);
expect(schema).toEqual({ type: "object", properties: {} });
expect(Object.getOwnPropertySymbols(schema)).toHaveLength(0);
});
});
describe("convertToolResult", () => {
it("converts text content blocks", () => {
const result: AgentToolResult<unknown> = {
content: [{ type: "text", text: "Hello world" }],
};
const mcpResult = convertToolResult(result);
expect(mcpResult.content).toEqual([{ type: "text", text: "Hello world" }]);
expect(mcpResult.isError).toBeUndefined();
});
it("converts image content blocks", () => {
const result: AgentToolResult<unknown> = {
content: [{ type: "image", data: "base64data", mimeType: "image/png" }],
};
const mcpResult = convertToolResult(result);
expect(mcpResult.content).toEqual([
{ type: "image", data: "base64data", mimeType: "image/png" },
]);
});
it("converts tool_error blocks with isError flag", () => {
const result: AgentToolResult<unknown> = {
content: [{ type: "tool_error", error: "Something went wrong" }],
};
const mcpResult = convertToolResult(result);
expect(mcpResult.content).toEqual([{ type: "text", text: "Something went wrong" }]);
expect(mcpResult.isError).toBe(true);
});
it("converts blocks with error field to isError", () => {
const result: AgentToolResult<unknown> = {
content: [{ type: "custom", error: "Custom error message" }],
};
const mcpResult = convertToolResult(result);
expect(mcpResult.isError).toBe(true);
expect(mcpResult.content[0]).toEqual({ type: "text", text: "Custom error message" });
});
it("returns (no output) for empty content", () => {
const result: AgentToolResult<unknown> = {
content: [],
};
const mcpResult = convertToolResult(result);
expect(mcpResult.content).toEqual([{ type: "text", text: "(no output)" }]);
});
it("serializes details as tool-details text block", () => {
const result: AgentToolResult<unknown> = {
content: [{ type: "text", text: "Output" }],
details: { key: "value", count: 42 },
};
const mcpResult = convertToolResult(result);
expect(mcpResult.content.length).toBe(2);
expect(mcpResult.content[1]).toMatchObject({
type: "text",
text: expect.stringContaining("<tool-details>"),
});
expect(mcpResult.content[1]).toMatchObject({
text: expect.stringContaining('"key": "value"'),
});
});
it("handles null details gracefully", () => {
const result: AgentToolResult<unknown> = {
content: [{ type: "text", text: "Output" }],
details: null,
};
const mcpResult = convertToolResult(result);
// Should not add a details block for null
expect(mcpResult.content.length).toBe(1);
});
it("handles multiple content blocks", () => {
const result: AgentToolResult<unknown> = {
content: [
{ type: "text", text: "Line 1" },
{ type: "text", text: "Line 2" },
{ type: "image", data: "abc", mimeType: "image/jpeg" },
],
};
const mcpResult = convertToolResult(result);
expect(mcpResult.content.length).toBe(3);
});
});
describe("wrapToolHandler", () => {
it("executes tool and converts result", async () => {
const mockExecute = vi.fn().mockResolvedValue({
content: [{ type: "text", text: "Tool output" }],
});
const tool = {
name: "test-tool",
execute: mockExecute,
} as unknown as AnyAgentTool;
const handler = wrapToolHandler(tool);
const result = await handler({ input: "test" });
expect(mockExecute).toHaveBeenCalledWith(
expect.stringContaining("mcp-bridge-test_tool"),
{ input: "test" },
undefined,
undefined,
);
expect(result.content).toEqual([{ type: "text", text: "Tool output" }]);
});
it("passes abort signal to tool execute", async () => {
const mockExecute = vi.fn().mockResolvedValue({
content: [{ type: "text", text: "Done" }],
});
const tool = {
name: "abort-aware-tool",
execute: mockExecute,
} as unknown as AnyAgentTool;
const controller = new AbortController();
const handler = wrapToolHandler(tool, controller.signal);
await handler({});
expect(mockExecute).toHaveBeenCalledWith(
expect.any(String),
{},
controller.signal,
undefined,
);
});
it("handles tool execution errors gracefully", async () => {
const mockExecute = vi.fn().mockRejectedValue(new Error("Tool crashed"));
const tool = {
name: "crashing-tool",
execute: mockExecute,
} as unknown as AnyAgentTool;
const handler = wrapToolHandler(tool);
const result = await handler({});
expect(result.isError).toBe(true);
expect(result.content[0]).toMatchObject({
type: "text",
text: expect.stringContaining("Tool error"),
});
});
it("returns abort message for AbortError", async () => {
const abortError = new Error("Aborted");
abortError.name = "AbortError";
const mockExecute = vi.fn().mockRejectedValue(abortError);
const tool = {
name: "abortable-tool",
execute: mockExecute,
} as unknown as AnyAgentTool;
const handler = wrapToolHandler(tool);
const result = await handler({});
expect(result.isError).toBe(true);
expect(result.content[0]).toMatchObject({
type: "text",
text: expect.stringContaining("was aborted"),
});
});
it("calls onToolUpdate callback with update data", async () => {
const mockExecute = vi.fn().mockImplementation(async (_id, _args, _signal, onUpdate) => {
onUpdate?.({ progress: 50 });
return { content: [{ type: "text", text: "Done" }] };
});
const tool = {
name: "updating-tool",
execute: mockExecute,
} as unknown as AnyAgentTool;
const onToolUpdate = vi.fn();
const handler = wrapToolHandler(tool, undefined, onToolUpdate);
await handler({});
expect(onToolUpdate).toHaveBeenCalledWith(
expect.objectContaining({
toolName: "updating_tool",
update: { progress: 50 },
}),
);
});
it("does not break execution when onToolUpdate callback throws", async () => {
const mockExecute = vi.fn().mockImplementation(async (_id, _args, _signal, onUpdate) => {
onUpdate?.({ progress: 100 });
return { content: [{ type: "text", text: "Success" }] };
});
const tool = {
name: "callback-error-tool",
execute: mockExecute,
} as unknown as AnyAgentTool;
const onToolUpdate = vi.fn().mockRejectedValue(new Error("Callback failed"));
const handler = wrapToolHandler(tool, undefined, onToolUpdate);
// Should not throw even if callback fails
const result = await handler({});
expect(result.content[0]).toMatchObject({
type: "text",
text: "Success",
});
});
it("generates unique tool call IDs for each invocation", async () => {
const toolCallIds: string[] = [];
const mockExecute = vi.fn().mockImplementation(async (id) => {
toolCallIds.push(id);
return { content: [{ type: "text", text: "Done" }] };
});
const tool = {
name: "unique-id-tool",
execute: mockExecute,
} as unknown as AnyAgentTool;
const handler = wrapToolHandler(tool);
await handler({});
await handler({});
expect(toolCallIds[0]).not.toBe(toolCallIds[1]);
expect(toolCallIds[0]).toContain("mcp-bridge-unique_id_tool");
expect(toolCallIds[1]).toContain("mcp-bridge-unique_id_tool");
});
});
describe("mcpToolName", () => {
it("formats tool name with server prefix", () => {
expect(mcpToolName("moltbot", "bash")).toBe("mcp__moltbot__bash");
});
it("handles multi-part tool names", () => {
expect(mcpToolName("my-server", "my_tool_name")).toBe("mcp__my-server__my_tool_name");
});
});
describe("buildMcpAllowedTools", () => {
it("builds allowed tools list from tool array", () => {
const tools = [{ name: "tool_a" }, { name: "tool_b" }] as AnyAgentTool[];
const allowed = buildMcpAllowedTools("moltbot", tools);
expect(allowed).toEqual(["mcp__moltbot__tool_a", "mcp__moltbot__tool_b"]);
});
it("returns empty array for empty tools list", () => {
const allowed = buildMcpAllowedTools("moltbot", []);
expect(allowed).toEqual([]);
});
});
describe("bridgeMoltbotToolsSync", () => {
// Helper to create a class-like McpServer mock (required because implementation uses `new`)
function createMockMcpServerClass(toolFn: ReturnType<typeof vi.fn>) {
return class MockMcpServer {
tool = toolFn;
constructor(_opts: { name: string; version: string }) {
// Constructor receives options
}
};
}
it("registers tools with MCP server", () => {
const registeredTools: string[] = [];
const toolFn = vi.fn((name: string) => {
registeredTools.push(name);
});
const MockMcpServer = createMockMcpServerClass(toolFn);
const tools = [
{
name: "tool_one",
description: "First tool",
parameters: { type: "object", properties: {} },
execute: vi.fn(),
},
{
name: "tool_two",
description: "Second tool",
parameters: { type: "object", properties: {} },
execute: vi.fn(),
},
] as unknown as AnyAgentTool[];
const result = bridgeMoltbotToolsSync({
name: "test-server",
tools,
McpServer: MockMcpServer as any,
});
expect(result.toolCount).toBe(2);
expect(result.registeredTools).toEqual(["tool_one", "tool_two"]);
expect(result.skippedTools).toEqual([]);
});
it("skips tools with empty names", () => {
const MockMcpServer = createMockMcpServerClass(vi.fn());
const tools = [
{ name: "", execute: vi.fn() },
{ name: " ", execute: vi.fn() },
{ name: "valid_tool", execute: vi.fn() },
] as unknown as AnyAgentTool[];
const result = bridgeMoltbotToolsSync({
name: "test-server",
tools,
McpServer: MockMcpServer as any,
});
expect(result.toolCount).toBe(1);
expect(result.registeredTools).toEqual(["valid_tool"]);
expect(result.skippedTools).toContain("(unnamed)");
});
it("returns correct server config structure", () => {
const MockMcpServer = createMockMcpServerClass(vi.fn());
const result = bridgeMoltbotToolsSync({
name: "my-server",
tools: [],
McpServer: MockMcpServer as any,
});
expect(result.serverConfig.type).toBe("sdk");
expect(result.serverConfig.name).toBe("my-server");
expect(result.serverConfig.instance).toBeInstanceOf(MockMcpServer);
});
it("handles tool registration errors gracefully", () => {
const toolFn = vi.fn((name: string) => {
if (name === "bad_tool") throw new Error("Registration failed");
});
const MockMcpServer = createMockMcpServerClass(toolFn);
const tools = [
{ name: "good_tool", execute: vi.fn() },
{ name: "bad_tool", execute: vi.fn() },
] as unknown as AnyAgentTool[];
const result = bridgeMoltbotToolsSync({
name: "test-server",
tools,
McpServer: MockMcpServer as any,
});
expect(result.registeredTools).toEqual(["good_tool"]);
expect(result.skippedTools).toContain("bad_tool");
});
});
});