Files
openclaw/src/agents/pi-tool-definition-adapter.test.ts

180 lines
5.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { AgentTool } from "@mariozechner/pi-agent-core";
import { Type } from "@sinclair/typebox";
import { describe, expect, it } from "vitest";
import type { ClientToolDefinition } from "./pi-embedded-runner/run/params.js";
import { toClientToolDefinitions, toToolDefinitions } from "./pi-tool-definition-adapter.js";
type ToolExecute = ReturnType<typeof toToolDefinitions>[number]["execute"];
const extensionContext = {} as Parameters<ToolExecute>[4];
async function executeThrowingTool(name: string, callId: string) {
const tool = {
name,
label: name === "bash" ? "Bash" : "Boom",
description: "throws",
parameters: Type.Object({}),
execute: async () => {
throw new Error("nope");
},
} satisfies AgentTool;
const defs = toToolDefinitions([tool]);
const def = defs[0];
if (!def) {
throw new Error("missing tool definition");
}
return await def.execute(callId, {}, undefined, undefined, extensionContext);
}
async function executeTool(tool: AgentTool, callId: string) {
const defs = toToolDefinitions([tool]);
const def = defs[0];
if (!def) {
throw new Error("missing tool definition");
}
return await def.execute(callId, {}, undefined, undefined, extensionContext);
}
describe("pi tool definition adapter", () => {
it("wraps tool errors into a tool result", async () => {
const result = await executeThrowingTool("boom", "call1");
expect(result.details).toMatchObject({
status: "error",
tool: "boom",
});
expect(result.details).toMatchObject({ error: "nope" });
expect(JSON.stringify(result.details)).not.toContain("\n at ");
});
it("normalizes exec tool aliases in error results", async () => {
const result = await executeThrowingTool("bash", "call2");
expect(result.details).toMatchObject({
status: "error",
tool: "exec",
error: "nope",
});
});
it("coerces details-only tool results to include content", async () => {
const tool = {
name: "memory_query",
label: "Memory Query",
description: "returns details only",
parameters: Type.Object({}),
execute: (async () => ({
details: {
hits: [{ id: "a1", score: 0.9 }],
},
})) as unknown as AgentTool["execute"],
} satisfies AgentTool;
const result = await executeTool(tool, "call3");
expect(result.details).toEqual({
hits: [{ id: "a1", score: 0.9 }],
});
expect(result.content[0]).toMatchObject({ type: "text" });
expect((result.content[0] as { text?: string }).text).toContain('"hits"');
});
it("coerces non-standard object results to include content", async () => {
const tool = {
name: "memory_query_raw",
label: "Memory Query Raw",
description: "returns plain object",
parameters: Type.Object({}),
execute: (async () => ({
count: 2,
ids: ["m1", "m2"],
})) as unknown as AgentTool["execute"],
} satisfies AgentTool;
const result = await executeTool(tool, "call4");
expect(result.details).toEqual({
count: 2,
ids: ["m1", "m2"],
});
expect(result.content[0]).toMatchObject({ type: "text" });
expect((result.content[0] as { text?: string }).text).toContain('"count"');
});
});
// ---------------------------------------------------------------------------
// toClientToolDefinitions streaming tool-call argument coercion (#57009)
// ---------------------------------------------------------------------------
function makeClientTool(name: string): ClientToolDefinition {
return {
type: "function",
function: {
name,
description: `${name} tool`,
parameters: { type: "object", properties: { query: { type: "string" } } },
},
};
}
async function executeClientTool(
params: unknown,
): Promise<{ calledWith: Record<string, unknown> | undefined }> {
let captured: Record<string, unknown> | undefined;
const [def] = toClientToolDefinitions([makeClientTool("search")], (_name, p) => {
captured = p;
});
if (!def) {
throw new Error("missing client tool definition");
}
await def.execute("call-c1", params, undefined, undefined, extensionContext);
return { calledWith: captured };
}
describe("toClientToolDefinitions param coercion", () => {
it("passes plain object params through unchanged", async () => {
const { calledWith } = await executeClientTool({ query: "hello" });
expect(calledWith).toEqual({ query: "hello" });
});
it("parses a JSON string into an object (streaming delta accumulation)", async () => {
const { calledWith } = await executeClientTool('{"query":"hello","limit":10}');
expect(calledWith).toEqual({ query: "hello", limit: 10 });
});
it("parses a JSON string with surrounding whitespace", async () => {
const { calledWith } = await executeClientTool(' {"query":"hello"} ');
expect(calledWith).toEqual({ query: "hello" });
});
it("falls back to empty object for invalid JSON string", async () => {
const { calledWith } = await executeClientTool("not-json");
expect(calledWith).toEqual({});
});
it("falls back to empty object for empty string", async () => {
const { calledWith } = await executeClientTool("");
expect(calledWith).toEqual({});
});
it("falls back to empty object for null", async () => {
const { calledWith } = await executeClientTool(null);
expect(calledWith).toEqual({});
});
it("falls back to empty object for undefined", async () => {
const { calledWith } = await executeClientTool(undefined);
expect(calledWith).toEqual({});
});
it("falls back to empty object for a JSON array string", async () => {
const { calledWith } = await executeClientTool("[1,2,3]");
expect(calledWith).toEqual({});
});
it("handles nested JSON string correctly", async () => {
const { calledWith } = await executeClientTool(
'{"action":"search","params":{"q":"test","page":1}}',
);
expect(calledWith).toEqual({ action: "search", params: { q: "test", page: 1 } });
});
});