Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,10 @@
"./config": {
"import": "./dist/config/index.js",
"types": "./dist/config/index.d.ts"
},
"./registry": {
"import": "./dist/registry/index.js",
"types": "./dist/registry/index.d.ts"
}
},
"workspaces": [
Expand Down
95 changes: 95 additions & 0 deletions src/__tests__/registry/register-and-execute.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { describe, it, expect } from "vitest";
import { DynamicToolRegistry } from "../../registry/index.js";
import type { DynamicToolState } from "../../registry/index.js";
import type { ToolResult } from "../../registry/index.js";

describe("DynamicToolRegistry — register and execute", () => {
it("registers a tool and executes it via asNode()", async () => {
const registry = new DynamicToolRegistry();

registry.register("greet", async (args) => ({
tool_name: "greet",
success: true,
output: `Hello, ${args.name}!`,
}));

const node = registry.asNode();

const state: DynamicToolState = {
messages: [],
pendingTools: [{ name: "greet", args: { name: "World" }, id: "tc-1" }],
};

const result = await node(state);
expect(result).toBeDefined();

const partial = result as Partial<DynamicToolState>;
expect(partial.messages).toHaveLength(1);

const msg = partial.messages![0];
expect(msg.role).toBe("tool");
expect(msg.name).toBe("greet");
expect(msg.tool_call_id).toBe("tc-1");

const body: ToolResult = JSON.parse(msg.content);
expect(body.success).toBe(true);
expect(body.output).toBe("Hello, World!");
expect(body.tool_name).toBe("greet");
});

it("returns empty update when pendingTools is empty", async () => {
const registry = new DynamicToolRegistry();
registry.register("noop", async () => ({
tool_name: "noop",
success: true,
output: "ok",
}));

const node = registry.asNode();
const result = await node({ messages: [], pendingTools: [] });
expect(result).toEqual({});
});

it("passes state to handler as second argument", async () => {
const registry = new DynamicToolRegistry();
let capturedState: Record<string, unknown> | undefined;

registry.register("spy", async (_args, state) => {
capturedState = state;
return { tool_name: "spy", success: true, output: "ok" };
});

const node = registry.asNode();
const state: DynamicToolState = {
messages: [{ role: "user", content: "hi" }],
pendingTools: [{ name: "spy", args: {}, id: "tc-2" }],
};

await node(state);
expect(capturedState).toBeDefined();
expect((capturedState as DynamicToolState).messages).toHaveLength(1);
});

it("catches handler exceptions and returns structured error", async () => {
const registry = new DynamicToolRegistry();

registry.register("boom", async () => {
throw new Error("something broke");
});

const node = registry.asNode();
const state: DynamicToolState = {
messages: [],
pendingTools: [{ name: "boom", args: {}, id: "tc-3" }],
};

const result = await node(state);
const partial = result as Partial<DynamicToolState>;
expect(partial.messages).toHaveLength(1);

const body: ToolResult = JSON.parse(partial.messages![0].content);
expect(body.success).toBe(false);
expect(body.error).toBe("something broke");
expect(body.tool_name).toBe("boom");
});
});
126 changes: 126 additions & 0 deletions src/__tests__/registry/register-unregister.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { describe, it, expect } from "vitest";
import { DynamicToolRegistry } from "../../registry/index.js";
import type { DynamicToolState } from "../../registry/index.js";
import type { ToolResult } from "../../registry/index.js";

describe("DynamicToolRegistry — register / unregister lifecycle", () => {
it("list() reflects registrations", () => {
const registry = new DynamicToolRegistry();
expect(registry.list()).toEqual([]);

registry.register("a", async () => ({
tool_name: "a", success: true, output: "ok",
}));
expect(registry.list()).toEqual(["a"]);

registry.register("b", async () => ({
tool_name: "b", success: true, output: "ok",
}));
expect(registry.list()).toEqual(["a", "b"]);
});

it("unregister removes the tool from list()", () => {
const registry = new DynamicToolRegistry();

registry.register("x", async () => ({
tool_name: "x", success: true, output: "ok",
}));
registry.register("y", async () => ({
tool_name: "y", success: true, output: "ok",
}));

expect(registry.list()).toContain("x");
expect(registry.unregister("x")).toBe(true);
expect(registry.list()).not.toContain("x");
expect(registry.list()).toContain("y");
});

it("unregister returns false for unknown tool", () => {
const registry = new DynamicToolRegistry();
expect(registry.unregister("nonexistent")).toBe(false);
});

it("has() returns correct value before and after unregister", () => {
const registry = new DynamicToolRegistry();

registry.register("tool", async () => ({
tool_name: "tool", success: true, output: "ok",
}));

expect(registry.has("tool")).toBe(true);
expect(registry.has("other")).toBe(false);

registry.unregister("tool");
expect(registry.has("tool")).toBe(false);
});

it("calling unregistered tool via asNode returns error result", async () => {
const registry = new DynamicToolRegistry();

registry.register("temp", async () => ({
tool_name: "temp", success: true, output: "ok",
}));

// Verify it works first
const node = registry.asNode();
const state: DynamicToolState = {
messages: [],
pendingTools: [{ name: "temp", args: {}, id: "tc-1" }],
};

const good = await node(state);
const goodBody: ToolResult = JSON.parse(
(good as Partial<DynamicToolState>).messages![0].content
);
expect(goodBody.success).toBe(true);

// Now unregister and try again
registry.unregister("temp");

const bad = await node(state);
const badBody: ToolResult = JSON.parse(
(bad as Partial<DynamicToolState>).messages![0].content
);
expect(badBody.success).toBe(false);
expect(badBody.error).toContain("not registered");
});

it("register() replaces existing handler (hot-swap)", async () => {
const registry = new DynamicToolRegistry();

registry.register("swap", async () => ({
tool_name: "swap", success: true, output: "v1",
}));

const node = registry.asNode();
const state: DynamicToolState = {
messages: [],
pendingTools: [{ name: "swap", args: {}, id: "tc-1" }],
};

const r1 = await node(state);
const b1: ToolResult = JSON.parse(
(r1 as Partial<DynamicToolState>).messages![0].content
);
expect(b1.output).toBe("v1");

// Replace with v2
registry.register("swap", async () => ({
tool_name: "swap", success: true, output: "v2",
}));

const r2 = await node(state);
const b2: ToolResult = JSON.parse(
(r2 as Partial<DynamicToolState>).messages![0].content
);
expect(b2.output).toBe("v2");
});

it("register() returns this for chaining", () => {
const registry = new DynamicToolRegistry();
const returned = registry.register("a", async () => ({
tool_name: "a", success: true, output: "ok",
}));
expect(returned).toBe(registry);
});
});
114 changes: 114 additions & 0 deletions src/__tests__/registry/schema-output.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { describe, it, expect } from "vitest";
import { DynamicToolRegistry } from "../../registry/index.js";

describe("DynamicToolRegistry — asSchema()", () => {
it("returns empty array when no tools registered", () => {
const registry = new DynamicToolRegistry();
expect(registry.asSchema()).toEqual([]);
});

it("returns valid function-calling schema for registered tools", () => {
const registry = new DynamicToolRegistry();

registry.register("read_file", async () => ({
tool_name: "read_file", success: true, output: "ok",
}), {
description: "Read a file",
parameters: {
type: "object",
properties: { path: { type: "string" } },
required: ["path"],
},
});

registry.register("write_file", async () => ({
tool_name: "write_file", success: true, output: "ok",
}), {
description: "Write a file",
parameters: {
type: "object",
properties: {
path: { type: "string" },
content: { type: "string" },
},
required: ["path", "content"],
},
});

registry.register("search", async () => ({
tool_name: "search", success: true, output: "ok",
}), {
description: "Search the web",
parameters: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
});

const schemas = registry.asSchema();
expect(schemas).toHaveLength(3);

// All entries have the correct shape
for (const entry of schemas) {
expect(entry.type).toBe("function");
expect(entry.function).toHaveProperty("name");
expect(entry.function).toHaveProperty("description");
expect(entry.function).toHaveProperty("parameters");
expect(typeof entry.function.name).toBe("string");
expect(typeof entry.function.description).toBe("string");
}

// Check specific tools
const names = schemas.map((s) => s.function.name);
expect(names).toEqual(["read_file", "write_file", "search"]);

// Check parameter schemas preserved
const readFile = schemas.find((s) => s.function.name === "read_file")!;
expect(readFile.function.description).toBe("Read a file");
expect(readFile.function.parameters).toEqual({
type: "object",
properties: { path: { type: "string" } },
required: ["path"],
});

const writeFile = schemas.find((s) => s.function.name === "write_file")!;
expect(writeFile.function.parameters).toHaveProperty("properties.path");
expect(writeFile.function.parameters).toHaveProperty("properties.content");
});

it("reflects live changes — unregistered tools disappear from schema", () => {
const registry = new DynamicToolRegistry();

registry.register("a", async () => ({
tool_name: "a", success: true, output: "ok",
}), { description: "Tool A" });

registry.register("b", async () => ({
tool_name: "b", success: true, output: "ok",
}), { description: "Tool B" });

expect(registry.asSchema()).toHaveLength(2);

registry.unregister("a");
const schemas = registry.asSchema();
expect(schemas).toHaveLength(1);
expect(schemas[0].function.name).toBe("b");
});

it("uses default description and parameters when opts omitted", () => {
const registry = new DynamicToolRegistry();

registry.register("bare", async () => ({
tool_name: "bare", success: true, output: "ok",
}));

const schemas = registry.asSchema();
expect(schemas).toHaveLength(1);
expect(schemas[0].function.description).toBe("");
expect(schemas[0].function.parameters).toEqual({
type: "object",
properties: {},
});
});
});
Loading