mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:30:42 +00:00
test: cover tool descriptor planner
This commit is contained in:
237
src/tools/availability.test.ts
Normal file
237
src/tools/availability.test.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { evaluateToolAvailability } from "./availability.js";
|
||||
import type { ToolDescriptor } from "./types.js";
|
||||
|
||||
const baseDescriptor: ToolDescriptor = {
|
||||
name: "example",
|
||||
description: "Example tool",
|
||||
inputSchema: { type: "object" },
|
||||
owner: { kind: "core" },
|
||||
executor: { kind: "core", executorId: "example" },
|
||||
};
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
||||
}
|
||||
|
||||
describe("evaluateToolAvailability", () => {
|
||||
it("treats descriptors without signals as available", () => {
|
||||
expect(evaluateToolAvailability({ descriptor: baseDescriptor })).toEqual([]);
|
||||
});
|
||||
|
||||
it("evaluates auth, env, config, plugin, and context signals from data only", () => {
|
||||
const descriptor: ToolDescriptor = {
|
||||
...baseDescriptor,
|
||||
availability: {
|
||||
allOf: [
|
||||
{ kind: "auth", providerId: "openai" },
|
||||
{ kind: "env", name: "OPENAI_API_KEY" },
|
||||
{ kind: "config", path: ["plugins", "entries", "demo", "config"], check: "non-empty" },
|
||||
{ kind: "plugin-enabled", pluginId: "demo" },
|
||||
{ kind: "context", key: "channel", equals: "telegram" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
evaluateToolAvailability({
|
||||
descriptor,
|
||||
context: {
|
||||
authProviderIds: new Set(["openai"]),
|
||||
env: { OPENAI_API_KEY: "set" },
|
||||
config: { plugins: { entries: { demo: { config: { mode: "local" } } } } },
|
||||
enabledPluginIds: new Set(["demo"]),
|
||||
values: { channel: "telegram" },
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns deterministic diagnostics for missing signals", () => {
|
||||
const descriptor: ToolDescriptor = {
|
||||
...baseDescriptor,
|
||||
availability: {
|
||||
allOf: [
|
||||
{ kind: "auth", providerId: "openai" },
|
||||
{ kind: "env", name: "OPENAI_API_KEY" },
|
||||
{ kind: "config", path: ["plugins", "entries", "demo", "config"], check: "non-empty" },
|
||||
{ kind: "plugin-enabled", pluginId: "demo" },
|
||||
{ kind: "context", key: "channel", equals: "telegram" },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
evaluateToolAvailability({
|
||||
descriptor,
|
||||
context: {
|
||||
authProviderIds: new Set(),
|
||||
env: {},
|
||||
config: { plugins: { entries: { demo: { config: {} } } } },
|
||||
enabledPluginIds: new Set(),
|
||||
values: { channel: "discord" },
|
||||
},
|
||||
}).map((entry) => entry.reason),
|
||||
).toEqual([
|
||||
"auth-missing",
|
||||
"env-missing",
|
||||
"config-missing",
|
||||
"plugin-disabled",
|
||||
"context-mismatch",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not treat credential config values as available without an injected resolver", () => {
|
||||
const descriptor: ToolDescriptor = {
|
||||
...baseDescriptor,
|
||||
availability: {
|
||||
kind: "config",
|
||||
path: ["models", "providers", "openai", "apiKey"],
|
||||
check: "available",
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
evaluateToolAvailability({
|
||||
descriptor,
|
||||
context: {
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
},
|
||||
}).map((entry) => entry.reason),
|
||||
).toEqual(["config-missing"]);
|
||||
});
|
||||
|
||||
it("accepts credential config values only through an injected availability resolver", () => {
|
||||
const descriptor: ToolDescriptor = {
|
||||
...baseDescriptor,
|
||||
availability: {
|
||||
kind: "config",
|
||||
path: ["models", "providers", "openai", "apiKey"],
|
||||
check: "available",
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
evaluateToolAvailability({
|
||||
descriptor,
|
||||
context: {
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
openai: {
|
||||
apiKey: { source: "env", provider: "default", id: "OPENAI_API_KEY" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: { OPENAI_API_KEY: "set" },
|
||||
isConfigValueAvailable: ({ value }) =>
|
||||
isRecord(value) &&
|
||||
value.source === "env" &&
|
||||
value.provider === "default" &&
|
||||
value.id === "OPENAI_API_KEY",
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not infer env-template strings as configured credentials", () => {
|
||||
const descriptor: ToolDescriptor = {
|
||||
...baseDescriptor,
|
||||
availability: {
|
||||
kind: "config",
|
||||
path: ["models", "providers", "openai", "apiKey"],
|
||||
check: "available",
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
evaluateToolAvailability({
|
||||
descriptor,
|
||||
context: {
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
openai: { apiKey: "${OPENAI_API_KEY}" },
|
||||
},
|
||||
},
|
||||
},
|
||||
env: { OPENAI_API_KEY: "set" },
|
||||
},
|
||||
}).map((entry) => entry.reason),
|
||||
).toEqual(["config-missing"]);
|
||||
});
|
||||
|
||||
it("does not infer ordinary objects with source/provider/id fields as credentials", () => {
|
||||
const descriptor: ToolDescriptor = {
|
||||
...baseDescriptor,
|
||||
availability: {
|
||||
kind: "config",
|
||||
path: ["tools", "example"],
|
||||
check: "non-empty",
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
evaluateToolAvailability({
|
||||
descriptor,
|
||||
context: {
|
||||
config: {
|
||||
tools: {
|
||||
example: { source: "manual", provider: "docs", id: "readme" },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("supports anyOf availability expressions", () => {
|
||||
const descriptor: ToolDescriptor = {
|
||||
...baseDescriptor,
|
||||
availability: {
|
||||
anyOf: [
|
||||
{ kind: "auth", providerId: "openai" },
|
||||
{ kind: "env", name: "OPENAI_API_KEY" },
|
||||
{
|
||||
allOf: [
|
||||
{ kind: "config", path: ["plugins", "entries", "local"], check: "non-empty" },
|
||||
{ kind: "plugin-enabled", pluginId: "local" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
evaluateToolAvailability({
|
||||
descriptor,
|
||||
context: {
|
||||
authProviderIds: new Set(),
|
||||
env: { OPENAI_API_KEY: "set" },
|
||||
enabledPluginIds: new Set(),
|
||||
},
|
||||
}),
|
||||
).toEqual([]);
|
||||
|
||||
expect(
|
||||
evaluateToolAvailability({
|
||||
descriptor,
|
||||
context: {
|
||||
authProviderIds: new Set(),
|
||||
env: {},
|
||||
enabledPluginIds: new Set(),
|
||||
},
|
||||
}).map((entry) => entry.reason),
|
||||
).toEqual(["auth-missing", "env-missing", "config-missing", "plugin-disabled"]);
|
||||
});
|
||||
});
|
||||
45
src/tools/boundary.test.ts
Normal file
45
src/tools/boundary.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const toolsDir = new URL("./", import.meta.url);
|
||||
const moduleReferencePattern =
|
||||
/\b(?:import|export)\s+(?:type\s+)?(?:[^"'`]*?\s+from\s+)?["']([^"']+)["']/gu;
|
||||
|
||||
function collectStaticModuleReferences(
|
||||
source: string,
|
||||
): readonly { line: number; specifier: string }[] {
|
||||
const references: { line: number; specifier: string }[] = [];
|
||||
const lines = source.split("\n");
|
||||
for (const [index, line] of lines.entries()) {
|
||||
const trimmed = line.trimStart();
|
||||
if (trimmed.startsWith("//")) {
|
||||
continue;
|
||||
}
|
||||
for (const match of line.matchAll(moduleReferencePattern)) {
|
||||
const specifier = match[1];
|
||||
if (specifier) {
|
||||
references.push({ line: index + 1, specifier });
|
||||
}
|
||||
}
|
||||
}
|
||||
return references;
|
||||
}
|
||||
|
||||
describe("tool system boundary", () => {
|
||||
it("keeps production tool modules independent from OpenClaw subsystems", () => {
|
||||
const violations = readdirSync(toolsDir, { withFileTypes: true }).flatMap((entry) => {
|
||||
if (!entry.isFile() || !entry.name.endsWith(".ts") || entry.name.endsWith(".test.ts")) {
|
||||
return [];
|
||||
}
|
||||
const source = readFileSync(new URL(entry.name, toolsDir), "utf8");
|
||||
return collectStaticModuleReferences(source)
|
||||
.filter(
|
||||
(reference) =>
|
||||
!reference.specifier.startsWith("./") && !reference.specifier.startsWith("node:"),
|
||||
)
|
||||
.map((reference) => `${entry.name}:${reference.line} ${reference.specifier}`);
|
||||
});
|
||||
|
||||
expect(violations).toEqual([]);
|
||||
});
|
||||
});
|
||||
122
src/tools/planner.test.ts
Normal file
122
src/tools/planner.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ToolPlanContractError } from "./diagnostics.js";
|
||||
import { formatToolExecutorRef } from "./execution.js";
|
||||
import { buildToolPlan } from "./planner.js";
|
||||
import { toToolProtocolDescriptors } from "./protocol.js";
|
||||
import type { ToolDescriptor } from "./types.js";
|
||||
|
||||
function descriptor(name: string, overrides: Partial<ToolDescriptor> = {}): ToolDescriptor {
|
||||
return {
|
||||
name,
|
||||
description: `${name} description`,
|
||||
inputSchema: { type: "object" },
|
||||
owner: { kind: "core" },
|
||||
executor: { kind: "core", executorId: name },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("buildToolPlan", () => {
|
||||
it("sorts visible and hidden tools deterministically", () => {
|
||||
const plan = buildToolPlan({
|
||||
descriptors: [
|
||||
descriptor("zeta"),
|
||||
descriptor("alpha"),
|
||||
descriptor("hidden", {
|
||||
sortKey: "middle",
|
||||
availability: { kind: "env", name: "MISSING_ENV" },
|
||||
}),
|
||||
],
|
||||
availability: { env: {} },
|
||||
});
|
||||
|
||||
expect(plan.visible.map((entry) => entry.descriptor.name)).toEqual(["alpha", "zeta"]);
|
||||
expect(plan.hidden.map((entry) => entry.descriptor.name)).toEqual(["hidden"]);
|
||||
expect(plan.hidden[0]?.diagnostics.map((entry) => entry.reason)).toEqual(["env-missing"]);
|
||||
});
|
||||
|
||||
it("fails deterministically on duplicate tool names", () => {
|
||||
let error: unknown;
|
||||
try {
|
||||
buildToolPlan({
|
||||
descriptors: [descriptor("read"), descriptor("read")],
|
||||
});
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(ToolPlanContractError);
|
||||
expect(error).toMatchObject({
|
||||
code: "duplicate-tool-name",
|
||||
toolName: "read",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed when a visible descriptor has no executor", () => {
|
||||
let error: unknown;
|
||||
try {
|
||||
buildToolPlan({
|
||||
descriptors: [descriptor("read", { executor: undefined })],
|
||||
});
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(ToolPlanContractError);
|
||||
expect(error).toMatchObject({
|
||||
code: "missing-executor",
|
||||
toolName: "read",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not require an executor for unavailable descriptors", () => {
|
||||
const plan = buildToolPlan({
|
||||
descriptors: [
|
||||
descriptor("plugin_tool", {
|
||||
executor: undefined,
|
||||
availability: { kind: "plugin-enabled", pluginId: "demo" },
|
||||
}),
|
||||
],
|
||||
availability: { enabledPluginIds: new Set() },
|
||||
});
|
||||
|
||||
expect(plan.visible).toEqual([]);
|
||||
expect(plan.hidden[0]?.descriptor.name).toBe("plugin_tool");
|
||||
expect(plan.hidden[0]?.diagnostics[0]?.reason).toBe("plugin-disabled");
|
||||
});
|
||||
|
||||
it("hides descriptors with malformed empty allOf availability", () => {
|
||||
const plan = buildToolPlan({
|
||||
descriptors: [descriptor("malformed", { availability: { allOf: [] } })],
|
||||
});
|
||||
|
||||
expect(plan.visible).toEqual([]);
|
||||
expect(plan.hidden[0]?.descriptor.name).toBe("malformed");
|
||||
expect(plan.hidden[0]?.diagnostics).toEqual([
|
||||
{
|
||||
reason: "unsupported-signal",
|
||||
message: "Empty availability allOf group",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps protocol conversion separate from executor refs and model normalization", () => {
|
||||
const plan = buildToolPlan({
|
||||
descriptors: [
|
||||
descriptor("plugin_tool", {
|
||||
owner: { kind: "plugin", pluginId: "demo" },
|
||||
executor: { kind: "plugin", pluginId: "demo", toolName: "plugin_tool" },
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(formatToolExecutorRef(plan.visible[0].executor)).toBe("plugin:demo:plugin_tool");
|
||||
expect(toToolProtocolDescriptors(plan.visible)).toEqual([
|
||||
{
|
||||
name: "plugin_tool",
|
||||
description: "plugin_tool description",
|
||||
inputSchema: { type: "object" },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user