import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import {
executeTool,
hasDynamicTool,
isDynamicToolRequiringApproval,
listDynamicToolDefinitions,
} from "../agent/tools.js";
import { PluginRegistry } from "./registry.js";
import type { PluginTool } from "./types.js";
function makeTool(name: string, overrides: Partial<PluginTool> = {}): PluginTool {
return {
name,
description: `tool ${name}`,
parameters: { type: "object", properties: {} },
execute: async () => ({ output: "ok", ok: true }),
...overrides,
};
}
describe("PluginRegistry.addTool", () => {
it("注册动态工具并可被 executeTool 侧查到", () => {
const registry = new PluginRegistry();
const result = registry.addTool("p1", makeTool("my_tool"));
expect(result.ok).toBe(true);
expect(hasDynamicTool("my_tool")).toBe(true);
expect(listDynamicToolDefinitions().map((d) => d.function.name)).toContain("my_tool");
registry.unregisterAll();
expect(hasDynamicTool("my_tool")).toBe(false);
});
it("重名注册被拒", () => {
const registry = new PluginRegistry();
expect(registry.addTool("p1", makeTool("dup_tool")).ok).toBe(true);
const second = registry.addTool("p2", makeTool("dup_tool"));
expect(second.ok).toBe(false);
expect(second.error).toContain("already registered");
registry.unregisterAll();
});
it("保留名被拒:内置名 / task / update_plan / browser_ 前缀", () => {
const registry = new PluginRegistry();
for (const name of ["list_dir", "read_file", "task", "update_plan", "browser_navigate", "browser_custom"]) {
const result = registry.addTool("p1", makeTool(name));
expect(result.ok, name).toBe(false);
expect(result.error, name).toContain("reserved");
}
registry.unregisterAll();
});
it("requiresApproval 透传到动态工具表", async () => {
const registry = new PluginRegistry();
registry.addTool("p1", makeTool("gate_tool", { requiresApproval: true }));
registry.addTool("p1", makeTool("free_tool"));
expect(isDynamicToolRequiringApproval("gate_tool")).toBe(true);
expect(isDynamicToolRequiringApproval("free_tool")).toBe(false);
const result = await executeTool("free_tool", {}, "D:/tmp");
expect(result.ok).toBe(true);
registry.unregisterAll();
});
});
describe("PluginRegistry hooks/commands", () => {
it("hook 按 event 分桶,hasHooks/hookEventsOf 正确", () => {
const registry = new PluginRegistry();
registry.addHook("p1", "tool_call", () => undefined);
registry.addHook("p2", "tool_call", () => undefined);
registry.addHook("p1", "turn_end", () => undefined);
expect(registry.getHookHandlers("tool_call")).toHaveLength(2);
expect(registry.hasHooks("tool_call")).toBe(true);
expect(registry.hasHooks("prompt_submit")).toBe(false);
expect(registry.hookEventsOf("p1").sort()).toEqual(["tool_call", "turn_end"]);
});
it("命令重名被拒,listCommands 带来源", () => {
const registry = new PluginRegistry();
expect(registry.addCommand("p1", { name: "deploy", run: () => "x" })).toBe(true);
expect(registry.addCommand("p2", { name: "deploy", run: () => "y" })).toBe(false);
expect(registry.listCommands()).toEqual([
{ name: "deploy", source: "plugin:p1" },
]);
});
});
describe("PluginRegistry.rollbackPlugin", () => {
it("只回滚指定插件的部分注册", () => {
const registry = new PluginRegistry();
registry.addTool("a", makeTool("tool_a"));
registry.addTool("b", makeTool("tool_b"));
registry.addHook("a", "tool_call", () => undefined);
registry.addHook("b", "tool_call", () => undefined);
registry.addCommand("a", { name: "cmd_a", run: () => "x" });
registry.rollbackPlugin("a");
expect(hasDynamicTool("tool_a")).toBe(false);
expect(hasDynamicTool("tool_b")).toBe(true);
expect(registry.getHookHandlers("tool_call")).toHaveLength(1);
expect(registry.getHookHandlers("tool_call")[0]?.pluginId).toBe("b");
expect(registry.getCommand("cmd_a")).toBeUndefined();
registry.unregisterAll();
});
});
describe("PluginRegistry.listDescriptors", () => {
it("汇总插件元数据与注册清单", () => {
const registry = new PluginRegistry();
registry.addPlugin("p1", "project", "D:/w/.meowcode/plugins/p1.ts");
registry.addTool("p1", makeTool("tool_x"));
registry.addCommand("p1", { name: "cmd_x", run: () => "x" });
registry.addHook("p1", "session_start", () => undefined);
const descriptors = registry.listDescriptors();
expect(descriptors).toEqual([
{
id: "p1",
path: "D:/w/.meowcode/plugins/p1.ts",
source: "project",
enabled: true,
toolNames: ["tool_x"],
commandNames: ["cmd_x"],
hookEvents: ["session_start"],
},
]);
});
});
describe("PluginRegistry.createApi", () => {
it("registerTool 重名时告警且不崩溃", () => {
const registry = new PluginRegistry();
const api1 = registry.createApi("p1", "D:/w", "C:/Users/t");
const api2 = registry.createApi("p2", "D:/w", "C:/Users/t");
api1.registerTool(makeTool("shared_tool"));
expect(() => api2.registerTool(makeTool("shared_tool"))).not.toThrow();
expect(registry.toolNamesOf("p2")).toHaveLength(0);
registry.unregisterAll();
});
it("api.workspace / storage 读写", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "meow-plugin-storage-"));
try {
const registry = new PluginRegistry();
const api = registry.createApi("p1", "D:/w", dir);
expect(api.workspace).toBe("D:/w");
await api.storage.set("counter", 41);
await api.storage.set("counter", 42);
expect(await api.storage.get<number>("counter")).toBe(42);
} finally {
await fs.rm(dir, { recursive: true, force: true });
}
});
});